OpenTelemetry for Python: Traces, Logs, and OTLP Setup
Last verified on Nov 8, 2026.
OpenTelemetry for Python gives you traces from your frameworks and libraries, and a handler that turns standard logging records into OpenTelemetry logs with the trace ID attached. The fastest route is opentelemetry-instrument, which wraps your start command. This guide covers that route, a manual setup for when you cannot wrap the command, trace IDs in JSON logs, and the pitfalls. Code was run with opentelemetry-sdk 1.45.2, distro 0.66b2, Flask 3.1, and Python 3.14.
Install and Bootstrap
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a requirementsThe distro installs the API, the SDK, and the opentelemetry-bootstrap and opentelemetry-instrument tools. Bootstrap reads your installed packages and lists the matching instrumentation libraries. The OpenTelemetry docs show -a install, which installs them straight into the environment. -a requirements prints pinned lines you can add to your dependency manifest instead, and Sazabi's docs recommend that for locked builds. In the test environment, it printed entries such as opentelemetry-instrumentation-logging==0.66b2 and opentelemetry-instrumentation-wsgi==0.66b2. Install the instrumentation for every framework and client you use, for example opentelemetry-instrumentation-flask.
Run With opentelemetry-instrument
Take a small Flask app with a custom span:
# app.py
import logging
from flask import Flask
from opentelemetry import trace
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
app = Flask(__name__)
@app.get("/order")
def order():
with tracer.start_as_current_span("charge-card") as span:
span.set_attribute("order.id", 4812)
logger.info("charging card order_id=%s", 4812)
return "ok"Start it through the wrapper, with configuration in environment variables:
export OTEL_SERVICE_NAME=checkout
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
opentelemetry-instrument flask --app app run --port 8080One request produced a GET /order server span and a charge-card span in the same trace, and one log record, at a local OTLP receiver. Set the protocol explicitly, because the opentelemetry-exporter-otlp package installs both the gRPC and the HTTP exporter. Set only the base endpoint: for OTLP/HTTP the SDK appends /v1/traces and /v1/logs itself.
Also by default, the same run sent metrics. Set OTEL_METRICS_EXPORTER=none if your backend takes only logs and traces.
The agent works by monkey patching, so everything it should patch must be installed first, and opentelemetry-instrument must be the outermost command. To keep health checks out of your traces, set OTEL_PYTHON_EXCLUDED_URLS to a comma-separated list of regular expressions such as healthcheck.
Logs: What Is Exported and What Is Not
The OpenTelemetry docs state that before Python 1.40.0 logs auto-instrumentation was off by default, and it is now on. opentelemetry-instrumentation-logging installs a handler that converts logging records into OpenTelemetry logs and exports them. In the test run, the Flask startup lines, the werkzeug access line, and both application messages arrived as log records, with the two application records carrying the trace ID of the request that produced them. Disable the handler with OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION=false.
The OTLP handler is separate from what prints to your console. Your own handlers and formatters keep working, so you can write JSON to stdout and export OTLP at the same time. Python's root logger defaults to WARNING, so call logging.basicConfig(level=logging.INFO) or the INFO records never reach the handler. See the Python logging guide for the configuration side.
Put Trace IDs in Your Console Logs
The exported records carry trace context automatically. For the lines you print, add it yourself. The least fragile way reads the active span in your formatter, which needs no environment variable:
import json
import logging
from opentelemetry import trace
class JsonFormatter(logging.Formatter):
def format(self, record):
entry = {
"time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
ctx = trace.get_current_span().get_span_context()
if ctx.is_valid:
entry["trace_id"] = format(ctx.trace_id, "032x")
entry["span_id"] = format(ctx.span_id, "016x")
if record.exc_info:
entry["exception"] = self.formatException(record.exc_info)
return json.dumps(entry)Output from a request, with both lines in the same trace:
{"time": "2026-11-08T07:12:13+0000", "level": "info", "logger": "checkout", "message": "order received", "trace_id": "5cbabfcf80ad389372d2fb2d45d3166d", "span_id": "89120144e2a29bc6"}
{"time": "2026-11-08T07:12:13+0000", "level": "info", "logger": "checkout", "message": "charging card", "trace_id": "5cbabfcf80ad389372d2fb2d45d3166d", "span_id": "b22c3e8a8bdf662f"}The IDs are formatted as the lowercase hex a trace ID uses everywhere else. Lines logged outside any span have no valid context, so the fields are omitted.
The alternative is the instrumentation's own injection. Set OTEL_PYTHON_LOG_CORRELATION=true, and records gain otelTraceID, otelSpanID, otelServiceName, and otelTraceSampled. Watch one trap: that setting also makes the instrumentation call logging.basicConfig() with its own text format. In testing it ran first, so the app's own basicConfig(handlers=[...]) call became a no-op, and the output stayed text. Either pass force=True to your call or attach your handler directly. The trace ID doubles as a correlation ID for everything one request touched. For structlog users, the structlog guide shows the same lookup as a processor.
Manual Setup Without the Wrapper
When you cannot wrap the start command, such as under some process managers, configure the SDK in code:
import logging
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.logging.handler import LoggingHandler
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_otel(service_name: str):
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(tracer_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
set_logger_provider(logger_provider)
logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))
return tracer_provider, logger_providerCall setup_otel once at startup, and on exit call shutdown() on both providers so the last batch is sent. The exporters read OTEL_EXPORTER_OTLP_ENDPOINT. The handler comes from opentelemetry-instrumentation-logging. The older LoggingHandler inside opentelemetry-sdk still worked in testing but raised a DeprecationWarning telling you to use the instrumentation package's version.
Common Mistakes
- Using the SDK's deprecated
LoggingHandler. Import it fromopentelemetry.instrumentation.logging.handler. - Calling
basicConfigafter correlation is on. The instrumentation's call wins unless you passforce=True. - Leaving root at
WARNING.INFOlogs are dropped before export. - Initializing in the gunicorn master. Sazabi's docs say to call setup in each worker after the fork, using gunicorn's
post_forkhook or Celery'sworker_process_initsignal, and to shut the provider down at worker exit. - Using
bootstrap -a installin a locked build. It installs outside your manifest, so userequirements. - Forgetting that auto-instrumentation needs the wrapper first. Put
opentelemetry-instrumentin front of the command. - Unended spans. Use
with tracer.start_as_current_span(...)so they always end.
Production Tips
- Name every service with
OTEL_SERVICE_NAME. - Keep the endpoint and any headers in environment variables, not code.
- Exclude health checks with
OTEL_PYTHON_EXCLUDED_URLS. - Flush on shutdown, and test the handler path with a request before you rely on it.
- Read the logging best practices for field naming, and the OpenTelemetry Node.js guide if you run both stacks.
Sending Python Telemetry to Sazabi
Sazabi, an AI-native observability and security platform, accepts OTLP over HTTP. Its OpenTelemetry source docs describe two Python paths. For full auto-instrumentation, add the distro and the HTTP exporter and start with opentelemetry-instrument. For logs only, attach a LoggingHandler to a LoggerProvider as shown above. In both cases, set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf and OTEL_EXPORTER_OTLP_ENDPOINT to the intake URL from the log source's setup screen, then redeploy. The docs say metrics sent to this endpoint are not stored, so set OTEL_METRICS_EXPORTER=none or use the separate metrics source. Sazabi ingests OpenTelemetry metrics (Prometheus metrics through an OpenTelemetry Collector) and OTLP traces through the same intake as your logs. It stores each metric data point and each span as a log record that its agent queries alongside your logs, so there is no separate metrics store to run.
See the OpenTelemetry integration and traces integration, and the Sazabi guide to instrumenting traces.
Frequently Asked Questions
How do I add OpenTelemetry to a Python app?
Install opentelemetry-distro and an OTLP exporter, run opentelemetry-bootstrap to find instrumentation packages for your libraries, then start your app with opentelemetry-instrument and set the OTEL_* environment variables.
How do I put trace IDs in Python logs?
Read the current span with trace.get_current_span().get_span_context() in a logging formatter and add trace_id and span_id to each record, or set OTEL_PYTHON_LOG_CORRELATION=true to have the instrumentation inject otelTraceID and otelSpanID.
Does OpenTelemetry Python export logs automatically?
Since OpenTelemetry Python 1.40.0 the logging handler is installed automatically by opentelemetry-instrument. Before that it was disabled by default.
Why do logs or traces vanish under gunicorn?
Sazabi's docs say to initialize OpenTelemetry in each worker after the fork, using gunicorn's post_fork hook or Celery's worker_process_init signal, not in the master process.
Sources
- opentelemetry-distro, opentelemetry-exporter-otlp, opentelemetry-bootstrap, and opentelemetry-instrument configured through flags or environment variables (accessed Nov 8, 2026)
- OTEL_PYTHON_LOG_CORRELATION, OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION, OTEL_PYTHON_EXCLUDED_URLS, and that logs auto-instrumentation was off by default before OpenTelemetry Python 1.40.0 (accessed Nov 8, 2026)
- The distro installs the API, SDK, and the opentelemetry-bootstrap and opentelemetry-instrument tools, and Flask is the example framework (accessed Nov 8, 2026)
- OTLP over HTTP/protobuf with opentelemetry-exporter-otlp-proto-http, and the default span and log record processors (accessed Nov 8, 2026)
- The logging instrumentation converts Python log records to OpenTelemetry logs, replaces the deprecated handler in opentelemetry-sdk, and injects otelTraceID and otelSpanID into records when correlation is enabled (accessed Nov 8, 2026)
- For OTLP/HTTP the SDK builds signal-specific URLs such as /v1/traces from OTEL_EXPORTER_OTLP_ENDPOINT (accessed Nov 8, 2026)
- Sazabi accepts OTLP/HTTP at an intake URL with the http/protobuf protocol, documents opentelemetry-instrument and a logs-only LoggerProvider setup for Python, does not store metrics from that endpoint, and advises per-worker setup under gunicorn or Celery (accessed Nov 8, 2026)