Exporting Python Logs Through OpenTelemetry

The OpenTelemetry logs signal exists to make log records first-class telemetry: same resource attributes as your spans, same exception field names, same transport, same Collector pipeline. This page wires the standard library into it, covers what the bridge does to each record, and is honest about when writing JSON to stdout remains the better choice. It builds on correlating logs, traces and metrics, part of the distributed tracing and OpenTelemetry in Python section.

What the bridge does to a record A standard-library LogRecord on the left converted into an OpenTelemetry log record on the right. The message becomes the body. The numeric level becomes both a severity number on the OpenTelemetry scale and a severity text preserving the original name. The logger name becomes an instrumentation scope. Any extra attributes set on the record become log record attributes. The exception information, if present, is expanded into the exception semantic-convention attributes — type, message and stacktrace — matching the shape a span uses for a recorded exception. The active span context is read at conversion time and attached as trace id, span id and trace flags, with no filter required. And the resource from the LoggerProvider — service name, version and environment — is attached to every record without appearing on the original at all, which is the field set that makes the record joinable with spans and metrics from the same process. logging.LogRecord → OpenTelemetry log record the stdlib record getMessage() levelno = 40 · levelname name = "orders" extra: order_id = 8812 exc_info = (…) no service identity at all — the record does not know where it is Logging Handler the OTel record body severity_number = 17 · severity_text instrumentation scope attributes: order_id = 8812 exception.type · .message · .stacktrace trace_id · span_id · trace_flags resource: service.name · version · env the last two lines are what the bridge adds and a stdout formatter has to be told: the active span, and the service's identity and exception fields arrive under the same names a span uses, so one query shape covers both
The two bottom rows on the right are the reason to do this: identity and trace context arrive on every record without any per-record code.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-exporter-otlp-proto-grpc>=1.27.0,<2.0.0"
export OTEL_SERVICE_NAME=checkout-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_BLRP_MAX_QUEUE_SIZE=2048
export OTEL_BLRP_SCHEDULE_DELAY=5000

The Collector needs a logs pipeline to receive them:

service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/backend]

Implementation

Step 1 — Build the provider with the shared resource. The same Resource object the tracer and meter use, so all three signals agree.

import logging
from opentelemetry import _logs
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from observability.otel import RESOURCE          # the one shared object

logger_provider = LoggerProvider(resource=RESOURCE)
logger_provider.add_log_record_processor(
    BatchLogRecordProcessor(
        OTLPLogExporter(insecure=True),
        max_queue_size=2048,
        schedule_delay_millis=5000,
        max_export_batch_size=512,
    )
)
_logs.set_logger_provider(logger_provider)

Step 2 — Attach the bridge to the root logger. As a root handler it receives records from your code and from every dependency that propagates, which is the whole point of using the standard library as the front end.

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

Step 3 — Keep a stdout handler alongside it. A logs pipeline that silently drops is indistinguishable from a service that has nothing to say. Run both until the OTLP path has proven itself for a week.

import sys
from pythonjsonlogger import jsonlogger

stdout = logging.StreamHandler(sys.stdout)
stdout.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
root.addHandler(stdout)                            # belt and braces during the transition

Step 4 — Keep the trace-context filter too. The bridge captures the span itself, but the filter puts the IDs on the LogRecord, where the stdout formatter can also see them.

from observability.otel import TraceContextFilter

root.addFilter(TraceContextFilter())               # on the logger, so both handlers benefit
stdout or OTLP — what each one gives up Two ways of getting log records out of a Python process, compared on four properties. Writing JSON to stdout and letting the container runtime collect it has no in-process failure mode at all: the write is to a pipe, the platform owns durability, and a Collector outage is irrelevant. But the records carry only what the formatter was told to add, service identity has to be repeated in the formatter configuration, and the collection path is completely separate from the one traces and metrics use. Exporting over OTLP puts logs through the same Collector, so resource attributes are attached automatically, central redaction covers logs as well as spans, and there is one pipeline to operate. Its cost is a real in-process failure mode: a bounded queue that drops when the Collector is unavailable, exactly like the span queue. The recommendation drawn is to run both during a transition, because the failure signature of a silently dropping logs pipeline is indistinguishable from a service with nothing to report. two delivery paths, different failure models JSON to stdout no in-process failure mode a write to a pipe; the platform owns durability records carry what the formatter was told service identity repeated in the formatter a separate path from traces and metrics simple, durable, and disconnected from everything else you emit OTLP export a real in-process failure mode a bounded queue that drops, like the span queue resource attributes attached automatically central redaction covers logs too one pipeline for all three signals joined to everything else, at the cost of a dependency on the Collector being up run both while proving it — a silently dropping logs pipeline looks exactly like a quiet service
Neither is strictly better. stdout has no failure mode of its own; OTLP has one, and buys identity, redaction and a single pipeline in exchange.

Step 5 — Flush on shutdown. The log processor has the same bounded queue and the same shutdown obligation as the span processor.

import atexit

atexit.register(logger_provider.shutdown)          # drains the queue, then closes
Severity mapping, including the custom level Python's logging levels mapped onto OpenTelemetry severity numbers. DEBUG at ten becomes severity five, INFO at twenty becomes nine, WARNING at thirty becomes thirteen, ERROR at forty becomes seventeen and CRITICAL at fifty becomes twenty-one — an evenly spaced ladder with gaps deliberately left between the standard values so intermediate severities exist. A project-defined level such as AUDIT at twenty-five sits between INFO and WARNING and maps to the nearest lower standard severity, so it arrives as severity nine with its own severity text preserved: queries that filter on the numeric severity will group it with INFO, while queries that filter on the text can still isolate it. The practical note is that an alert built on the numeric severity of a custom level will not behave as its name suggests, and the text field is the one to use for anything project-specific. Python level → OpenTelemetry severity Python OpenTelemetry DEBUG · 10 DEBUG · 5 INFO · 20 INFO · 9 AUDIT · 25 — yours INFO · 9, text "AUDIT" WARNING · 30 WARN · 13 ERROR · 40 · CRITICAL · 50 ERROR · 17 · FATAL · 21 a custom level keeps its name in severity_text and groups with the nearest lower number — filter on the text, not the number
A custom level survives by name and is grouped by number. An alert on the numeric severity of AUDIT fires on every INFO record too.

Configuration options

Option Env var Default Recommended
Endpoint OTEL_EXPORTER_OTLP_ENDPOINT localhost:4317 the local Collector
Queue size OTEL_BLRP_MAX_QUEUE_SIZE 2048 a few seconds of record volume
Batch size OTEL_BLRP_MAX_EXPORT_BATCH_SIZE 512 512
Schedule delay OTEL_BLRP_SCHEDULE_DELAY 5000 ms 5000 ms
Handler level code NOTSET INFO
Handler placement code root logger
Stdout handler code keep, during transition
Resource code per provider the shared object

Verification

Emit one record inside a span and read both the stdout copy and the Collector's debug output.

import logging
from opentelemetry import trace

configure()                                        # tracing + logs, sharing the resource
tracer = trace.get_tracer("probe")
log = logging.getLogger("orders")

with tracer.start_as_current_span("probe-span"):
    try:
        raise ValueError("payment declined")
    except ValueError:
        log.exception("order failed", extra={"order_id": 8812})

Expected Output (stdout copy):

{"asctime": "2026-08-02 14:31:07,220", "levelname": "ERROR", "name": "orders",
 "message": "order failed", "order_id": 8812,
 "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7"}

Expected Output (Collector debug exporter):

LogRecord #0
Severity: ERROR (17)
Body: order failed
Attributes:
     -> order_id: Int(8812)
     -> exception.type: Str(ValueError)
     -> exception.message: Str(payment declined)
     -> exception.stacktrace: Str(Traceback (most recent call last)…)
Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
Span ID: 00f067aa0ba902b7
Resource attributes:
     -> service.name: Str(checkout-api)
     -> service.version: Str(2026.8.1)

Three things to check: the severity number is 17 rather than 40, the exception is expanded into three attributes under the semantic-convention names, and the resource attributes are present without appearing anywhere in the logging call.

Common mistakes

Records arrive with no trace ID

Error signature: Trace ID is empty on the Collector side although the code runs inside a span. Root cause: the record was created outside the span's context — commonly on a background thread, or in a task created before the span started. Remediation: check where the record is produced. The bridge reads the active span at conversion time, so a record produced where no span is current genuinely has none.

The logs pipeline silently drops everything

Error signature: no log records at the backend, no errors anywhere, and the service is clearly running. Root cause: the Collector has no logs pipeline, so it accepts the records on the OTLP receiver and has nowhere to send them. Remediation: add the logs pipeline to service.pipelines, and keep the stdout handler until it is confirmed.

Duplicate records after adding the bridge

Error signature: every line appears twice. Root cause: the bridge handler was added to a named logger that also propagates to a root logger carrying the same handler. Remediation: attach the bridge once, at the root, and let propagation do the rest — the same rule as any root-handler setup, described in taming third-party library loggers.

Deciding whether to adopt it

The logs signal is the newest of the three and the one where "should we" is a real question rather than a formality. Four considerations decide it, and the answer differs by deployment more than by preference.

Do your logs already reach a backend that ingests all three signals? If the destination is a system that stores traces, metrics and logs together and joins them for you, sending logs over OTLP puts them in the same pipeline with the same resource attributes and the same redaction — which is most of the value. If logs go somewhere else entirely, the OTLP path adds a transport without adding a join.

Is central redaction worth having? A Collector attribute processor that hashes db.statement covers spans today; extending it to log records is one pipeline entry once the records arrive as OTLP. In a mixed-language fleet that single policy point is often the strongest argument on this page.

Can you tolerate an in-process failure mode? stdout has none: the write goes to a pipe and the platform owns the rest. OTLP export introduces a bounded queue that drops under pressure, which is the same trade the span pipeline makes and a new one for logs.

Is anything depending on the file or stream format? An on-host agent, a security tool tailing a file, a support workflow that reads container logs — all of them break if stdout stops carrying the records. That is the most common practical blocker, and it is also the reason the transition period with both paths running is worth the duplication.

Consideration Favours stdout Favours OTLP
Backend ingests all three signals strongly
Central redaction wanted strongly
No in-process failure mode acceptable strongly
An on-host consumer reads the stream strongly
Resource attributes needed on records yes
Simplest possible operation yes

Severity, and what to do about custom levels

The mapping is fixed and mostly unsurprising, with one wrinkle worth planning around: a project-defined level between two standard ones maps to the nearest lower standard severity number and keeps its own name in severity_text. An AUDIT level at 25 therefore arrives as severity 9 — the same number as INFO — with the text AUDIT.

That is usually the right behaviour, and it means anything keyed on the numeric severity groups audit records with informational ones. If audit records need to be distinguishable by a numeric filter, the practical options are to place the custom level at a standard number's value or to carry the distinction as an attribute rather than as a level. The second is generally better: a level is a coarse severity axis, and "this is an audit event" is a category rather than a severity.

logger.info("permission granted", extra={"event_class": "audit", "principal": principal})

What this does not replace

Two things the OTLP logs path is sometimes expected to do and does not.

It is not a durability mechanism. The bounded queue drops under pressure and nothing is written to disk, so a record that matters for audit or compliance needs a path that does not lose data quietly — a file with an agent, or a write to a store, rather than a telemetry pipeline whose design principle is to shed load rather than block.

And it is not a substitute for structured fields. A record whose body is a formatted sentence arrives as a formatted sentence with a resource attached; the fields that make it queryable still have to be set at the call site through extra. The transport improves correlation and identity; it does nothing for a log line that was never structured in the first place, which is the subject of structured logging with the Python standard library.

Frequently Asked Questions

Is the OpenTelemetry logs signal stable in Python?

The API for bridging existing logging libraries is stable enough for production use and is what the SDK's LoggingHandler implements; the module names still carry an underscore prefix in the Python SDK, which signals that the direct-emit API is not finalised. Bridging the standard library is the supported pattern and the one to build on — you are not expected to call the logs API directly from application code.

Should I replace stdout JSON logging with OTLP export?

Not immediately, and often not at all. Writing JSON to stdout and letting the platform collect it is simple, has no in-process failure mode, and survives a Collector outage. OTLP export is worth it when you want log records to carry resource attributes automatically, to be redacted centrally alongside traces, or to reach a backend that ingests all three signals through one endpoint. Running both during the transition is the safe path.

How does severity map across?

The handler maps Python's numeric levels to OpenTelemetry severity numbers: DEBUG becomes 5, INFO 9, WARNING 13, ERROR 17 and CRITICAL 21, with the original level name preserved as the severity text. A custom level in between maps to the nearest lower standard severity, so a level 25 audit record arrives as INFO severity with its own text — which is usually what you want, but worth knowing before you build an alert on the numeric field.

Do log records get the trace ID automatically?

Yes — the bridge reads the active span context when it converts the record, so trace_id and span_id are set on the emitted log record without a filter. The filter is still worth keeping because it puts those IDs on the LogRecord itself, where any other handler, such as a stdout JSON formatter, can also see them.

What happens to exc_info?

The handler maps it to the exception semantic-convention attributes — exception.type, exception.message and exception.stacktrace — which is the same shape a span uses for a recorded exception. That is one of the concrete wins of the OTLP path: an exception in a log record and the same exception on a span are queryable with the same field names.