azure

Replacing the Azure Monitor SDK with Vanilla OpenTelemetry

Azure Monitor quietly shipped a native OTLP endpoint. Here's how to send traces, metrics, and logs to App Insights from a plain Python app — no vendor SDK, your instrumentation stays portable.

Most guides for getting telemetry into Application Insights start the same way: install azure-monitor-opentelemetry, call configure_azure_monitor\(\), done. It works. It also quietly marries your codebase to one vendor. Swap to Grafana or Honeycomb later and you’re rewriting instrumentation, not changing a URL.

There’s a better option now, Every App Insights resource in Azure created with OTLP support turned on exposes three native OTLP endpoints — traces, metrics, logs. You point a standards-compliant OpenTelemetry exporter at them and you’re done. No Azure SDK. No Collector sitting in the middle. Just OpenTelemetry talking to an endpoint, the same way it would talk to any other backend.

This post builds it up one signal at a time on a small FastAPI todo service. Start with a single trace, add metrics, add logs, then talk about why this matters more in 2026 than it did two years ago.

Before you write any code#

Two things have to be true or nothing below works.

First, the App Insights resource needs OTLP support switched on. You set this when you create the resource — there’s an OTLP support toggle on the Basics tab. Fair warning: it’s a one-way door. Once on, you can’t turn it off, and the resource can no longer move between resource groups or subscriptions. Worth knowing before you flip it on something you care about.

After deployment, the resource’s Overview page grows an OTLP Connection Info section with your three endpoint URLs. Copy all three.

Second, auth. These endpoints only accept Entra (Azure AD) tokens — instrumentation keys and connection strings are rejected outright, regardless of your “disable local auth” setting. The identity sending data needs the Monitoring Metrics Publisher role on the Data Collection Rule that Azure auto-created alongside the resource. The Connection Info panel links straight to that DCR; assign the role from its Access control (IAM) blade to your az login user for local dev, or to the managed identity in production.

Miss that role and you’ll get a 403 with an unhelpful body. The exporter sees a response, reports success, and your data never shows up. If something silently fails to land later, check this first.

One more constraint from the portal text: these endpoints only speak OTLP/HTTP with binary Protobuf. No JSON, no gRPC. The Python opentelemetry-exporter-otlp-proto-http package does exactly this by default, so there’s nothing to configure.

Step 1 — One trace#

Start with the smallest thing that proves the pipe works: a single span exported to the traces endpoint.

The only Azure-specific wrinkle is the token. Every export needs a fresh bearer token, so wrap the standard OTLP exporter to refresh it:

import time
from azure.identity import DefaultAzureCredential
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

_credential = DefaultAzureCredential()
_SCOPE = "https://monitor.azure.com/.default"

class EntraSpanExporter(OTLPSpanExporter):
    def export(self, spans):
        token = _credential.get_token(_SCOPE)
        self._headers.update({"Authorization": f"Bearer {token.token}"})
        return super().export(spans)

DefaultAzureCredential handles the token source for you — your az login session locally, a managed identity in Azure, a service principal from env vars. The same code runs in all three.

Wire it into a tracer provider and instrument FastAPI:

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

resource = Resource.create({"service.name": "todo-api"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(
    EntraSpanExporter(endpoint=TRACES_ENDPOINT)
))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("todo-api")

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

That FastAPIInstrumentor line matters more than it looks. It creates a SERVER span for every HTTP request — and that’s what gives you a waterfall. Without it, every span you create by hand is an orphaned INTERNAL span with no parent request to hang under, and App Insights shows you flat rows instead of a tree.

Now a handler with a custom child span:

@app.post("/todos")
def create_todo(body: TodoIn):
    with tracer.start_as_current_span("create_todo") as span:
        todo_id = str(uuid.uuid4())
        span.set_attribute("todo.id", todo_id)
        with tracer.start_as_current_span("db.insert"):
            time.sleep(0.005)  # pretend write
            _todos[todo_id] = {"id": todo_id, "title": body.title}
        return _todos[todo_id]

Fire one request, wait about two minutes, and go to Investigate → Search in your App Insights resource. Filter the event type to Request, click the row, and you’ll see the hierarchy: POST /todoscreate_tododb.insert.

A naming heads-up that trips up everyone the first time. Azure’s vocabulary predates OpenTelemetry, so the labels don’t match what you wrote:

What you sentWhere it lands
SERVER span (the request)requests table
INTERNAL / CLIENT spandependencies table
A full tracea “transaction”
trace_idoperation_Id

The traces table, confusingly, holds none of these — it holds text logs, which we’ll get to. Query spans from requests and dependencies, never traces.

Step 2 — Add metrics#

Same pattern, different endpoint and one important catch. Wrap the metric exporter the same way:

from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter

class EntraMetricExporter(OTLPMetricExporter):
    def export(self, metrics_data, **kwargs):
        token = _credential.get_token(_SCOPE)
        self._headers.update({"Authorization": f"Bearer {token.token}"})
        return super().export(metrics_data, **kwargs)

The catch: Azure requires delta temporality. The OTel SDK defaults to cumulative, and if you send cumulative metrics they’re accepted at the wire level and then silently dropped — no error, just missing data. You have to declare delta for every instrument type up front:

from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
    PeriodicExportingMetricReader, AggregationTemporality,
)

delta = {
    metrics.Counter: AggregationTemporality.DELTA,
    metrics.Histogram: AggregationTemporality.DELTA,
    metrics.UpDownCounter: AggregationTemporality.DELTA,
    metrics.ObservableCounter: AggregationTemporality.DELTA,
    metrics.ObservableUpDownCounter: AggregationTemporality.DELTA,
    metrics.ObservableGauge: AggregationTemporality.DELTA,
}

reader = PeriodicExportingMetricReader(
    EntraMetricExporter(endpoint=METRICS_ENDPOINT, preferred_temporality=delta),
    export_interval_millis=60_000,
)
metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[reader]))
meter = metrics.get_meter("todo-api")

todos_created = meter.create_counter("todos.created")

Then record inside the handler — todos_created.add\(1\) after a successful insert. Metrics batch up and export on the interval (60s here), so they won’t appear as instantly as traces.

Find them under Monitoring → Metrics, namespace todo-api, or query customMetrics in the Logs blade.

One more detail you only notice by reading the URLs: the metrics endpoint sits on a different host from traces and logs — metrics.ingest.monitor.azure.com versus plain ingest.monitor.azure.com. Traces and logs share the same domain; metrics is its own. Nothing documents this clearly; you spot it by comparing the three URLs the portal handed you.

Step 3 — Add logs#

Logs close the loop, and the nice part is you don’t change how you log. You bridge Python’s standard logging module into OpenTelemetry, and your existing logger.info\(...\) calls start flowing to the logs endpoint.

import logging
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter

class EntraLogExporter(OTLPLogExporter):
    def export(self, batch):
        token = _credential.get_token(_SCOPE)
        self._headers.update({"Authorization": f"Bearer {token.token}"})
        return super().export(batch)

log_provider = LoggerProvider(resource=resource)
log_provider.add_log_record_processor(
    BatchLogRecordProcessor(EntraLogExporter(endpoint=LOGS_ENDPOINT))
)
set_logger_provider(log_provider)

logging.getLogger().addHandler(
    LoggingHandler(level=logging.INFO, logger_provider=log_provider)
)
logging.getLogger().setLevel(logging.INFO)

logger = logging.getLogger("todo-api")

Now logger.info\("Todo created", extra=\{"todo_id": todo_id\}\) inside the handler ships through the OTel pipeline. Because the log record is emitted inside the active span, it’s automatically correlated — App Insights ties the log line to the exact request that produced it.

This is where Azure’s naming bites a second time. Those text logs land in the traces table — the same table name OpenTelemetry uses for spans, holding the opposite thing. Query them with traces | where message has "Todo created" and try not to think about it too hard.

Shut the app down cleanly so nothing is stranded in a buffer:

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    trace.get_tracer_provider().force_flush()
    metrics.get_meter_provider().shutdown()
    log_provider.force_flush()

What you actually built#

Look at the full dependency list:

fastapi
uvicorn
azure-identity
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-http
opentelemetry-instrumentation-fastapi

One Azure package — azure-identity — and it’s only there to fetch a token. Everything touching telemetry is plain OpenTelemetry. Nothing in the instrumentation knows it’s talking to Azure.

Which is the entire point. Swap TRACES_ENDPOINT from the Azure URL to http://localhost:4318/v1/traces pointing at a local Grafana Tempo, drop the Entra header (Tempo doesn’t need one), and the same app exports the same spans to a completely different backend. One environment variable. No code change.

That’s the test for whether your telemetry is yours or rented. If swapping the endpoint breaks everything because you’re wired into configure_azure_monitor\(\) or AddApplicationInsightsTelemetry\(\), the data isn’t portable. If swapping the endpoint is a config change, it is.

Closing thoughts#

In 2026, OTLP is supported natively by Azure Monitor, Grafana Cloud, Datadog, Honeycomb, and basically every backend worth naming. The vendor SDK has stopped being a convenience and started being a liability.

None of this is as discoverable as it should be(Maybe because we are still early, as of June 2026 the OLTP endpoint are still in preview)t. But it works, it’s standards-compliant, and the next time you need to add a second backend or rip Azure out entirely, you’ll change a URL instead of a codebase.