Adding Trace IDs to Python Log Records

Correlating a log line with the distributed trace that produced it means injecting the active trace_id and span_id into every LogRecord, then emitting those identifiers as discrete fields so a backend can join logs to spans. This page is for backend engineers and SREs who already emit JSON logs and already run OpenTelemetry, but still cannot answer "show me every log line from this one slow request". It is part of the Python Logging Fundamentals and Structured Data guide and extends the formatter configuration material with a single focused, production-ready correlation recipe.

From active span context to a correlatable log field The active span context, held in a context variable, is read by a LogRecordFactory that formats the trace and span identifiers as zero-padded hex and stamps them onto every LogRecord. A JSON formatter serializes them as discrete keys and the log backend uses them to join a line to its span. When no span is active the same factory writes zero placeholders so the field set never changes. Active span trace_id + span_id live in a contextvar LogRecordFactory formats 032x / 016x hex sets record attributes LogRecord + trace_id, span_id + trace_flags JSON formatter emits discrete keys one line per event Log backend filter by trace_id jump to the span tree No active span 32 and 16 zeros keep the schema stable read stamp serialize query no valid context
The correlation path: the factory reads the span context once per record, and the same code path writes zeros when there is no span, so the JSON key set never changes.

Prerequisites

Install the OpenTelemetry API and SDK with pinned version ranges. The API exposes the current span context and is the only import the injection code needs; the SDK plus at least one instrumentation library produce the real spans without which every identifier reads as zeros.

pip install "opentelemetry-api>=1.30.0,<2.0.0" \
            "opentelemetry-sdk>=1.30.0,<2.0.0"
The three layers a non-zero trace_id depends on A stack of three layers in the process: opentelemetry-api reads the active span context, opentelemetry-sdk creates and samples real spans, and instrumentation libraries start a span per request or task. A callout notes that if any layer is missing every record reads as thirty-two zeros. every layer must be live in the same process opentelemetry-api reads the active span context opentelemetry-sdk creates and samples real spans instrumentation libraries start a span per request or task Miss any layer and every record reads 32 zeros
The API alone compiles and runs, but it can only report what the SDK and instrumentation actually created.

No environment variable is strictly required by the correlation logic itself, but a tracer provider must be configured somewhere in the process, and naming the service keeps the correlated records attributable once several services write into the same index.

export OTEL_SERVICE_NAME="checkout"
export OTEL_TRACES_EXPORTER="otlp"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://collector:4317"

If you have not set a provider up yet, the distributed tracing and OpenTelemetry in Python guide covers provider lifecycle and the OpenTelemetry SDK setup page covers processor and exporter wiring. Context propagation and baggage explains how a trace_id travels between processes, which is what makes the same identifier appear in the logs of both the caller and the callee.

Implementation

The correlation pipeline has three responsibilities: read the active span context, attach its identifiers to each LogRecord, and serialize those identifiers as discrete JSON fields. Everything else — handlers, levels, transports — stays exactly as it is.

One log call, traced through the injection path Sequence of messages between application code, the span context held in a context variable, the LogRecordFactory and the JSON formatter. Inside an active span the factory reads a valid SpanContext and writes hex identifiers. Outside any span the same call returns the invalid span and the factory writes zeros instead. Application log.info(...) Span context held in a contextvar LogRecordFactory wraps the default JSON formatter serializes the record Inside an active span logging creates the record get_current_span() SpanContext, is_valid=True trace_id / span_id as hex Outside any span get_current_span() INVALID_SPAN, zeros
The same three calls run for every record; only the validity of the returned SpanContext differs between the two passes.

Step 1 — Read the active span context. Call opentelemetry.trace.get_current_span() and inspect its get_span_context(). The returned SpanContext carries integer trace_id and span_id values plus an is_valid flag that is False when no span is recording. The identifiers are plain Python integers, not strings, so a 128-bit trace ID can be a very large number; you must format the integers as zero-padded lowercase hex to match the W3C Trace Context representation, using width 32 for the trace ID and 16 for the span ID. Reading the context this way is cheap and lock-free, because the active span lives in a context variable rather than shared mutable state — the same mechanism described in context variables and thread safety — which is why injection on the hot path adds negligible overhead per record.

Step 2 — Stamp the identifiers onto every record with a LogRecordFactory. The factory wraps the default record constructor and is invoked for every record the logging system creates, so you avoid attaching a filter to each handler. This is the most robust injection point available in the standard library.

import logging
from opentelemetry import trace

# Capture the original factory so we can delegate to it.
_old_factory = logging.getLogRecordFactory()


def _record_factory(*args, **kwargs):
    record = _old_factory(*args, **kwargs)
    span = trace.get_current_span()
    ctx = span.get_span_context()
    if ctx.is_valid:
        # Zero-padded lowercase hex matching W3C Trace Context.
        record.trace_id = format(ctx.trace_id, "032x")
        record.span_id = format(ctx.span_id, "016x")
        # trace_flags is an int bitmask; 01 means sampled.
        record.trace_flags = format(ctx.trace_flags, "02x")
    else:
        # Keep the keys present so downstream schemas stay stable.
        record.trace_id = "0" * 32
        record.span_id = "0" * 16
        record.trace_flags = "00"
    return record


logging.setLogRecordFactory(_record_factory)

The factory approach has one important property: it runs for records created anywhere in the process, including records emitted by third-party libraries you do not control. That is usually what you want, because a database driver or web framework that logs a slow query should carry the same trace_id as your own handlers. The cost is global mutation of process state, so install the factory exactly once during startup, before any framework has begun serving, and keep a reference to the previous factory as shown above so the chain stays intact if another component installed its own factory first.

Step 3 — Emit the fields from a JSON formatter. Because the attributes now exist on every record, the formatter reads them with plain attribute access. Use getattr defaults so the formatter still works if the factory was never installed — during a unit test, for example, or in a management command that imports the formatter without running startup. Keeping the field present even when empty matters for downstream schemas: a pipeline that indexes on trace_id will reject or mis-map records where the key is sometimes absent, so an explicit zero placeholder is safer than omitting it. The same serialization discipline is covered end to end in structured logging with the Python standard library.

import json
import logging


class TraceJSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            # getattr defaults keep the formatter usable without the factory.
            "trace_id": getattr(record, "trace_id", "0" * 32),
            "span_id": getattr(record, "span_id", "0" * 16),
            "trace_flags": getattr(record, "trace_flags", "00"),
        }
        if record.exc_info:
            # One escaped string, so line-delimited parsers stay intact.
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, separators=(",", ":"))

Step 4 — Wire it together and emit inside a span. Attach the formatter to a stream handler and log from within an active span so the identifiers are populated. In a real service this wiring belongs in a dictConfig dictionary rather than imperative calls; see configuring logging with dictConfig for the declarative form, which references TraceJSONFormatter by its dotted path under formatters.

import logging
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider

trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer("checkout")

handler = logging.StreamHandler()
handler.setFormatter(TraceJSONFormatter())
log = logging.getLogger("checkout")
log.setLevel(logging.INFO)
log.addHandler(handler)
log.propagate = False  # avoid a duplicate line from the root handler

with tracer.start_as_current_span("process_payment"):
    log.info("payment authorized", extra={"order_id": "A-9182"})

Expected Output:

{"timestamp":"2026-06-19T10:14:02+0000","level":"INFO","logger":"checkout","message":"payment authorized","trace_id":"7651916a3df52b3f86d0c2a1bb9f4e10","span_id":"3a1f9c5d2e7b0846","trace_flags":"01"}

Alternative injection points

The record factory is the default recommendation, but two other seams exist and each is right in a specific situation.

Choosing the injection seam A decision node asks who builds the record and who owns the seam. Three outcomes follow: a LogRecordFactory when nothing else has installed one, giving process-wide coverage; a logging Filter when a vendor agent already owns the factory, giving per-handler coverage; and a structlog processor when events are built by structlog, giving per-pipeline coverage. Who builds the record, and who owns the seam? LogRecordFactory nothing else has installed one covers the process logging.Filter a vendor agent already owns the factory covers one handler structlog processor events are built by structlog, not stdlib covers one pipeline
Three seams, three coverage scopes — pick by what already owns the record-building path in your process.

A logging.Filter mutates the record in place and returns True so the record is still emitted. Use it when another library already owns the global factory, or when only some handlers should carry the identifiers — an audit handler that must not leak internal identifiers, for instance. The trade-off is coverage: a filter attached to a handler runs only for records that actually reach that handler, so records dropped by a level check or emitted through a logger you forgot to wire arrive without the fields.

import logging
from opentelemetry import trace


class TraceContextFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        ctx = trace.get_current_span().get_span_context()
        # Filters must return True or the record is dropped entirely.
        record.trace_id = format(ctx.trace_id, "032x") if ctx.is_valid else "0" * 32
        record.span_id = format(ctx.span_id, "016x") if ctx.is_valid else "0" * 16
        return True


handler.addFilter(TraceContextFilter())

A structlog processor is the right seam when your service builds events through structlog rather than the standard library. The identifiers are added to the event dictionary by a processor placed before the renderer, which is the structlog analogue of the factory; binding context variables in structlog covers where in the chain such a processor belongs. If both stacks run in one process — structlog for application events, stdlib for library output — install the factory and the processor so both paths carry the same identifiers.

Configuration options

Choice Option When to use
Injection point LogRecordFactory Default; stamps every record process-wide with no per-handler wiring, including third-party library output.
Injection point logging.Filter When the factory is already owned by another component, or you need identifiers on only some handlers.
Trace ID format format(id, "032x") W3C Trace Context hex expected by Tempo, Jaeger, and effectively every backend.
Trace ID format raw int Only if a downstream consumer requires the integer; avoid, because it will not match stored spans.
Missing-span value "0" * 32 Explicit zeros keep the field present and queryable for records emitted outside any request.
Missing-span value omit field Smaller lines, but breaks schemas and index mappings that require a fixed key set.
Field names trace_id / span_id Matches OpenTelemetry log data model naming and most backends' auto-detected link fields.
Field names dd.trace_id-style Only when a vendor agent requires its own prefixed keys; emit both if you are mid-migration.
What each injection seam actually covers A matrix with three rows — LogRecordFactory, logging.Filter and a structlog processor — scored against four columns: your own loggers, third-party libraries, per-handler control and no global state. The factory covers your own loggers and third-party libraries but offers no per-handler control and does mutate global state. The filter covers your own loggers, partially covers third-party libraries, and keeps both per-handler control and freedom from global state. The structlog processor covers your own loggers only. injection seam your own loggers third-party libraries per-handler control no global state LogRecordFactory wraps the constructor logging.Filter attached per handler structlog processor in the processor chain covered partial not covered
The factory trades per-handler control and a clean global namespace for the one thing the others cannot give you: coverage of code you do not own.

Two options deserve emphasis. Emitting trace_flags is optional but cheap, and it lets you explain a missing trace: a line with trace_flags of 00 was produced inside a span the sampler discarded, so the log exists while the trace does not — see sampling strategies for distributed tracing for how that decision is made. And the field names matter more than they look: backends that offer a one-click jump from a log line to its trace usually key on exactly trace_id and span_id, so renaming them costs you the feature that motivated the work.

Verification

Run the wired example and confirm three things in the emitted line: trace_id is 32 hex characters, span_id is 16 hex characters, and trace_flags is 01 when the span is sampled. Then prove the factory degrades correctly by logging once outside any span and asserting the zero placeholders appear.

log.info("startup complete")  # no active span

Expected Output:

{"timestamp":"2026-07-25T10:14:00+0000","level":"INFO","logger":"checkout","message":"startup complete","trace_id":"00000000000000000000000000000000","span_id":"0000000000000000","trace_flags":"00"}
Reading a correlated log line, field by field One emitted JSON line is shown with three highlighted values. The trace_id is thirty-two lowercase hex characters, the span_id is sixteen, and trace_flags of 01 means the span was sampled. An arrow leads from the trace_id to a trace view where the same identifier selects the span tree for that request. one emitted line, wrapped for the diagram {"timestamp":"2026-06-19T10:14:02+0000","level":"INFO", "trace_id":"7651916a3df52b3f86d0c2a1bb9f4e10","span_id":"3a1f9c5d2e7b0846","trace_flags":"01"} trace_id 32 lowercase hex chars, zero-padded span_id 16 hex chars, the innermost active span trace_flags 01 means sampled, so the trace exists to open query by trace_id trace 7651916a3df52b3f86d0c2a1bb9f4e10 POST /checkout — 120 ms charge_card — 84 ms db.insert — 22 ms
The same 32-character identifier that appears in the log line selects the span tree in the trace view — and the jump works in both directions.

A unit test pins the behaviour so a future refactor cannot quietly remove the factory. Constructing a record through the installed factory exercises the real injection path without needing a live span.

import logging

record = logging.getLogRecordFactory()("t", logging.INFO, __file__, 1, "x", None, None)
assert len(record.trace_id) == 32
assert len(record.span_id) == 16
assert record.trace_flags == "00"  # no span active inside the test

Once the fields reach your backend, correlation becomes a query rather than a guess. In a trace-aware log store you filter by the trace_id shown on a slow or failed span and immediately see every line that request produced across every service, provided each service injects the identifiers and shares context. The reverse jump also works: copy the trace_id out of a log line into the trace view and open the full span tree. If only one side of a call shows the identifier, the problem is propagation rather than logging, and the fix belongs in context propagation and baggage — for message-driven services, specifically propagating trace context across Celery tasks.

Common mistakes

Diagnosing a trace_id of all zeros A troubleshooting tree. First ask whether a tracer provider is configured; if not, configure one because the API alone always returns the invalid span. Then ask whether the log call runs inside the span's context; if not, re-attach the context across the thread or task boundary. Then ask whether a factory and a filter both write the field; if they do, keep one writer. Otherwise check the hex format. Is a tracer provider configured at all? Does the log call run inside the span? Do a factory and a filter both write it? no no yes yes yes no Configure a provider the API alone always returns the non-recording invalid span Re-attach the context a raw thread or executor does not inherit the contextvar Keep one writer the later seam overwrites a valid id with a detached one Check the hex format format(id, "032x") — never hex() or 032X
Work down the branches: provider, then context, then duplicate seams — and only then suspect the formatting.
  • Error signature: every field is present but trace_id is 00000000000000000000000000000000 for records emitted from a worker thread, while request-thread records are correct. Root cause: the OpenTelemetry context lives in a context variable, and a thread started with threading.Thread or a callback handed to a raw ThreadPoolExecutor does not inherit it, so get_current_span() returns the non-recording default span. Remediation: capture the context with context.get_current() before dispatching and re-attach it inside the worker, or hand the executor a contextvars.copy_context()-wrapped callable — the boundary rules are set out in context variables and thread safety and applied to request identifiers in using contextvars for request tracing.

  • Error signature: the backend shows logs and traces side by side but the "view trace" link never resolves, and the log field reads 156802340019... or an uppercase hex string. Root cause: the integer SpanContext.trace_id was serialized directly, or formatted with hex() or "032X", none of which match the lowercase zero-padded 32-character form the backend stored. Remediation: always render with format(ctx.trace_id, "032x") and format(ctx.span_id, "016x"), and add a length assertion to the test above so a regression fails in CI rather than in an incident.

  • Error signature: identifiers are correct for most lines but occasionally show a stale trace from a previous request, most visibly under load. Root cause: both a record factory and a handler filter set the same attributes, and the filter runs later against a context that has already been detached — the last writer wins and overwrites a valid identifier. Remediation: choose one injection point and delete the other, then grep the codebase for setLogRecordFactory and addFilter at startup to confirm only one path assigns trace_id.

  • Error signature: correlated fields are perfect in development but the service's P99 latency rises after enabling JSON logging in production. Root cause: the injection is not the cost — the synchronous handler write is, and it now runs on the request thread for every line the factory has made worth keeping. Remediation: move emission off the hot path with the queue-based pattern in non-blocking logging with QueueHandler; the factory has already stamped the identifiers before the record is enqueued, so correlation survives the hand-off to the listener thread.

Frequently Asked Questions

Why is trace_id always 0 in my log records?

A trace_id of 0 (rendered as 32 zeros) means there is no active span in the current context when the log call runs. Either instrumentation has not started a span yet, or the log call executes outside the span's context, for example in a background thread that did not inherit the OpenTelemetry context.

Should I use a logging.Filter or a LogRecordFactory to inject trace IDs?

A LogRecordFactory injects the IDs into every record globally with no per-logger wiring, which is the most reliable approach. A Filter must be attached to each handler or logger and is skipped for records created by other means, so prefer the factory unless you need per-handler control.

Do I need the full OpenTelemetry SDK just to add trace IDs to logs?

You need the API to read the current span context, and an SDK plus instrumentation to actually create spans. Reading trace_id with the API alone returns zeros unless something is producing real spans, so a configured tracer provider is required for meaningful correlation.

How should trace IDs be formatted for backends like Grafana Tempo or Jaeger?

Emit the trace ID as a lowercase 32-character hex string and the span ID as 16 hex characters, matching the W3C Trace Context format. Most backends expect this exact zero-padded hex representation to link a log line to its trace.

Does injecting trace IDs on every record hurt throughput?

No. Reading the active span is a context-variable lookup and the two hex conversions are a few microseconds, which is far below the cost of serializing and writing the line. The expensive part of logging remains the handler I/O, so move that off the request thread with a queue-based handler rather than trimming the injection step.