Skip to content

OpenTelemetry trace correlation

azure-functions-logging can bind the Azure Functions host's W3C trace context into your handler so that OpenTelemetry log records inherit the host invocation span's trace_id and span_id. This makes your structured logs correlate with the distributed trace in Application Insights — without this package creating, recording, or exporting any spans.

What this feature does

  • Reads the host-provided traceparent / tracestate from the invocation context and attaches the extracted W3C context for the duration of the handler (via logging_context / with_context).
  • While that context is active, any OpenTelemetry LoggingHandler stamps emitted records with the host span's trace_id / span_id.
  • Emits trace_id and span_id in JsonFormatter output regardless of OpenTelemetry availability (they are null when no context is present).

What this feature does not do

  • It does not start, record, or export spans — it only activates the context the host already produced.
  • It does not replace azure-monitor-opentelemetry / configure_azure_monitor(). You still configure the exporter yourself; this package only ensures the host span context is active while your handler runs.
  • It is not a general distributed-tracing library.

Installation

The base install stays zero-dependency. Trace-context activation requires the optional [otel] extra:

pip install "azure-functions-logging[otel]"

When OpenTelemetry is not installed, activation degrades to a silent no-op — your logs still emit trace_id: null / span_id: null and nothing raises.

Enabling activation

Activation is opt-in. The value is resolved in this order (highest priority first):

  1. The per-call activate_trace_context= argument on logging_context() / @with_context(...), when not None.
  2. The process-wide default set by setup_logging(activate_trace_context=True).
  3. False (the built-in default — no activation).
from azure_functions_logging import setup_logging, logging_context

# Option A: process-wide default
setup_logging(activate_trace_context=True)

def handler(req, context):
    with logging_context(context):  # inherits the default → activates
        ...

# Option B: per-call override (wins over the default either way)
def handler(req, context):
    with logging_context(context, activate_trace_context=True):
        ...

You can also use the low-level activated_trace_context() context manager directly if you manage trace headers yourself.

Required call order

The OpenTelemetry LoggingHandler is attached to the root logger by configure_azure_monitor(). setup_logging() only decorates handlers that already exist when it runs. Call configure_azure_monitor() before setup_logging() so that context filters — and any RedactionFilter / SamplingFilter you attach — actually land on the OTel handler:

from azure.monitor.opentelemetry import configure_azure_monitor
from azure_functions_logging import setup_logging

configure_azure_monitor()                    # 1. attaches the OTel handler
setup_logging(activate_trace_context=True)   # 2. decorates the now-present handler

configure_azure_monitor() installs the non-deprecated opentelemetry.instrumentation.logging.LoggingHandler. This package detects any handler whose class lives under the opentelemetry.* namespace, so both that handler and the older opentelemetry.sdk._logs.LoggingHandler are recognised — you do not need to pin a specific handler class.

If you reverse the order, the OTel handler is added after setup_logging() and never receives your filters — PII redaction is silently bypassed. See Troubleshooting: PII appears in Application Insights attributes.

For handlers that must be attached after setup_logging(), pass use_record_factory=True so context is injected at record-creation time instead of via handler filters.

use_record_factory=True covers context injection only. It guarantees that trace_id / span_id / invocation fields reach records emitted through late-attached handlers, but it does not attach RedactionFilter, SamplingFilter, or AttributeFlattenFilter to those handlers. Security and noise filters must still be added explicitly to any OTel handler created after setup_logging() (e.g. by a later configure_azure_monitor()) — otherwise PII redaction and sampling are silently bypassed on that handler.

Filters in OpenTelemetry mode

In OTel mode the entire extra mapping is exported as log attributes, which makes RedactionFilter and SamplingFilter more important, not less — attach them to the OTel handler. Nested dict values in extra are silently dropped by the OTel SDK; use AttributeFlattenFilter to flatten them into dotted scalar keys (order={"id": 1}order.id=1).

import logging

from azure_functions_logging import (
    AttributeFlattenFilter,
    RedactionFilter,
    setup_logging,
)

setup_logging()
for handler in logging.getLogger().handlers:
    handler.addFilter(RedactionFilter())        # mask PII before export
    handler.addFilter(AttributeFlattenFilter())  # keep nested dicts from being dropped

Scope AttributeFlattenFilter to the OTel handler. The filter mutates the shared LogRecord in place, so if the same filter also runs on a standalone JsonFormatter stream handler your plain JSON output is flattened too: a nested extra={"order": {"id": 1}} is emitted as {"order.id": 1} instead of a nested object. That is what you want for OTel attribute export, but usually not for local/standalone JSON logs — attach it to the specific OTel handler rather than the root logger when both kinds of handler are present.

Handler scoping reduces blast radius but does not isolate mutation. stdlib logging passes the same LogRecord object to every handler in sequence, so if the OTel handler runs before a JSON/stdout handler on the same logger, that later handler still sees the flattened record. Scoping the filter to the OTel handler shrinks the window but cannot fully isolate in-place mutation when multiple handlers process one record. To keep plain JSON output nested, emit it on a separate logger (not sharing handlers with the OTel path).

Known limitation: thread boundaries

Trace-context activation uses contextvars, which do not automatically propagate across thread boundaries. Logs emitted from loop.run_in_executor(...) or a ThreadPoolExecutor worker lose the host span correlation unless you propagate the context yourself (e.g. by capturing contextvars.copy_context() and running the work inside it). Async code on the same event loop is unaffected.

Behavior change: spans you create become children of the host span

While activation is on, the host invocation span is the current span for the duration of your handler. Any span you start inside the handler — e.g. tracer.start_as_current_span("work") — is therefore parented to the host span instead of becoming a new top-level (root) span. This is usually what you want (your work nests under the invocation in the Application Insights end-to-end transaction), but it is a trace-structure change for code that previously created top-level spans. If you need a detached root span, capture a fresh root context yourself before starting it.

Runnable example

A minimal Function App wiring azure-monitor-opentelemetry together with this package lives in examples/otel_app.

Verified in Azure Application Insights

The examples/otel_app app was deployed to a real Azure Function App (Flex Consumption, Python 3.11) with a workspace-based Application Insights resource, then driven with live traffic. The screenshots below are the actual query results.

Querying a workspace-based Application Insights resource. When Application Insights is workspace-based, telemetry lands in the backing Log Analytics workspace under the App* tables (AppTraces, AppRequests, AppDependencies, AppExceptions) — not the classic traces / requests tables. Query the workspace directly (Logs blade, or az monitor log-analytics query -w <workspace-id> --analytics-query "...").

Telemetry is flowing. A single union over the App* tables confirms the exporter is delivering traces, requests, dependencies, and exceptions:

Application Insights — telemetry record counts by table

PII redaction works end to end. The app logs extra={"order_id": ..., "password": "should-be-masked"}. RedactionFilter, attached to the OTel LoggingHandler, masks password to *** before it becomes an exported attribute — so the raw value never reaches Application Insights. Every returned row shows the mask:

Application Insights — password redacted to *** in AppTraces

Logs correlate with their invocation. Joining AppRequests to AppTraces on OperationId shows that each Processing order log carries the same trace_id as the host invocation span for its HTTP request — the end-to-end correlation activate_trace_context=True provides, without this package ever creating a span:

Application Insights — request/trace correlation on OperationId

The same correlation in the Application Insights portal

The KQL results above prove the correlation at the query layer. The portal views below show the same deployed app the way you would actually explore it — without writing a single query.

Application map. The Function App node (func-aflog-otel-5597) and its downstream HTTP dependency are stitched together from the exported request/dependency telemetry:

Application Insights — Application map for the OpenTelemetry example app

End-to-end correlation, before and after. The clearest proof is the same process_order invocation queried the same way, with activate_trace_context off versus on. The projection surfaces Application Insights' own correlation keys (operation_Id, operation_ParentId) next to the trace_id / span_id this package writes as custom dimensions.

Without activation, every worker log record lands with operation_Id and operation_ParentId all zeros — orphaned from the host invocation at the correlation layer, even though the custom-dimension trace_id / span_id are present:

Application Insights — worker logs orphaned with all-zero Operation ID

With activate_trace_context=True, the four records of the invocation share a real operation_Id / operation_ParentId, and operation_Id now equals the package's custom-dimension trace_id — the log records are bound to the host invocation span, without this package ever creating one:

Application Insights — worker logs correlated under a real Operation ID

Correlated log records. Expanding Traces & events lists the worker's own Processing order log records under the same Operation ID as the host invocation span — the correlation activate_trace_context=True provides. Each trace carries the invocation's InvocationId / functionName in its custom properties, so the worker logs are no longer orphaned from the invocation:

Application Insights — worker log records correlated under one Operation ID