Skip to main content

Python

This guide covers general-purpose OpenTelemetry setup for Python — installing packages, zero-code auto-instrumentation, database instrumentation, log correlation, and exporting logs through the OTLP logs pipeline. The steps here apply to any Python application regardless of framework. Logs are exported to CtrlB via OTLP.

For production deployment topics — Gunicorn, uWSGI, gevent, ASGI servers, Celery, Docker, and Kubernetes — see the Deployment section. For web-framework-specific instrumentation, see the Flask and Django sections.

OpenTelemetry SDK status (mid-2026): Python log instrumentation is Development. See the OpenTelemetry language status table.

Signal stability in Python

SignalPython SDK statusNotes
LogsIn developmentFunctional and widely deployed, but the modules are underscore-prefixed (opentelemetry.sdk._logs) and breaking changes still land between SDK releases.

Two practical consequences of the logs status:

  • Log-related imports come from opentelemetry._logs and opentelemetry.sdk._logs — the leading underscore is the project's way of signalling that the surface may still change.
  • Recent SDK releases have reshaped the logs pipeline as part of stabilization: LogData was removed and several Log* classes were renamed to LogRecord*. The bridge from stdlib logging to OTLP export is LoggingHandler in opentelemetry.sdk._logs. Pin your opentelemetry-sdk version in requirements.txt and read the changelog before upgrading if you rely on logs.

None of this should stop you from using OpenTelemetry logs today — the bridge into Python's standard logging module works well and gives you automatic trace/log correlation. It just means you should treat log-pipeline code as something to revisit at upgrade time.

Prerequisites


Step 1. Install Dependencies

You can install OpenTelemetry in two main ways—depending on whether you want all instrumentations or just specific ones.

The opentelemetry-distro package installs the SDK, API, and the opentelemetry-bootstrap CLI. After installing, run opentelemetry-bootstrap -a install to scan your Python environment and install matching instrumentation packages for every detected library.

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install

opentelemetry-bootstrap inspects your installed site-packages and installs the corresponding instrumentation libraries (e.g., opentelemetry-instrumentation-requests if requests is present). Always run it after installing your application's dependencies. Re-run it whenever you add a new dependency.

For tighter control over what gets instrumented and vendored, install only the packages you need:

pip install opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp-proto-http \
opentelemetry-instrumentation-requests \
opentelemetry-instrumentation-psycopg2 \
opentelemetry-instrumentation-celery

Pin them in your requirements.txt:

opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-http
opentelemetry-instrumentation-requests
opentelemetry-instrumentation-psycopg2
opentelemetry-instrumentation-celery
pip install -r requirements.txt

Browse all available instrumentation packages in the OpenTelemetry Python Contrib repository and the OpenTelemetry Registry.


Step 2. Configure OpenTelemetry Instrumentation

Zero-code auto-instrumentation

The opentelemetry-instrument CLI wraps your application and automatically patches supported libraries at startup — no code changes required:

opentelemetry-instrument python app.py

All configuration is driven by environment variables (see Step 3 and Environment Variables). This is the fastest way to start collecting telemetry.

opentelemetry-instrument discovers every instrumentation package installed in the current environment and activates it. If you only installed specific packages in Step 1B, only those libraries will be instrumented.


How Python Auto-Instrumentation Works

opentelemetry-instrument is a CLI launcher that monkey-patches supported libraries before your application imports them. Each opentelemetry-instrumentation-* package registers a setuptools entry point under the opentelemetry_instrumentor group. At startup, the launcher:

  1. Discovers all registered entry points in the current environment
  2. Calls each instrumentor's instrument() method, which patches the target library's module-level objects
  3. Executes your application code

This is why import order matters. The patches must be applied before your code imports the target library. It is also why forking servers, dev-server reloaders, and gevent monkey-patching can break instrumentation — each of these mechanisms can cause your application code to run in a context where the patches were never applied.


Database Instrumentation

Each database client library requires its own instrumentation package. Auto-instrumentation (opentelemetry-bootstrap -a install) detects most of these automatically.

LibraryInstrumentation PackageNotes
psycopg2opentelemetry-instrumentation-psycopg2psycopg2-binary may not be auto-detected by bootstrap; install explicitly
psycopg (v3)opentelemetry-instrumentation-psycopgSeparate from psycopg2; supports async
asyncpgopentelemetry-instrumentation-asyncpgFor async PostgreSQL
SQLAlchemyopentelemetry-instrumentation-sqlalchemyPass engine= to instrument() for targeted instrumentation
pymysqlopentelemetry-instrumentation-pymysql
mysqlclientopentelemetry-instrumentation-mysqlclient
redisopentelemetry-instrumentation-redis
sqlite3opentelemetry-instrumentation-sqlite3stdlib; auto-detected

Log Correlation

The Python SDK can inject trace_id, span_id, and service.name into stdlib logging records, making it possible to correlate logs with traces in CtrlB.

Enable via environment variable:

export OTEL_PYTHON_LOG_CORRELATION=true

Custom log format to include trace context:

import logging

logging.basicConfig(
format="%(asctime)s %(levelname)s [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s] %(name)s - %(message)s",
level=logging.INFO,
)

When OTEL_PYTHON_LOG_CORRELATION=true, the SDK installs a logging filter that adds otelTraceID, otelSpanID, otelTraceSampled, and otelServiceName attributes to every log record. Outside an active span, these fields are empty strings.

structlog / loguru: These libraries do not use stdlib logging filters. For structlog, add a custom processor that reads from trace.get_current_span(). For loguru, use logger.configure(patcher=...) to inject trace context. The OTEL community maintains experimental packages for both.


Exporting Logs via the OTLP Logs Pipeline

Log Correlation (above) injects trace IDs into your existing log output for a scrape-and-forward path. To instead ship logs as OTLP log records directly to CtrlB, wire up the logs pipeline: a LoggerProvider with a BatchLogRecordProcessor wrapping the OTLP log exporter, plus a bridge into Python's standard logging module so your existing logging.info(...) calls become OTLP records — with trace_id/span_id attached automatically when emitted inside a span.

The bridge from stdlib logging to the OTLP pipeline is LoggingHandler in opentelemetry.sdk._logs (included in opentelemetry-sdk). The exporter reads the standard OTEL_EXPORTER_OTLP_* environment variables from Step 3, so no endpoint or auth is hard-coded here:

import logging

from opentelemetry import _logs, trace
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "<service_name>"})

logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(OTLPLogExporter())
)
_logs.set_logger_provider(logger_provider)

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

log = logging.getLogger("checkout")
tracer = trace.get_tracer("checkout")

with tracer.start_as_current_span("place_order"):
# This record carries the active trace_id/span_id automatically.
log.info("order placed", extra={"order.id": "ord-1234"})

logger_provider.shutdown() # flush pending records before exit

A few points that matter:

  • Note the underscore-prefixed imports (opentelemetry._logs, opentelemetry.sdk._logs) — the leading underscore signals that the logs surface may still change. Pin your opentelemetry-sdk version and read the changelog before upgrading.
  • Root-logger reach. Because records flow through the standard logging module, third-party library logs are captured too once the handler is on the root logger.
  • Trace correlation is the payoff. In CtrlB you can jump from a slow span straight to the log lines it produced.

Log Correlation vs. OTLP export. These are complementary, not interchangeable. Log Correlation above injects otelTraceID/otelSpanID into stdlib log format strings for scrape-and-forward setups — via OTEL_PYTHON_LOG_CORRELATION=true or, programmatically, LoggingInstrumentor().instrument() from opentelemetry-instrumentation-logging. That package does not export logs. LoggingHandler is the standard mechanism for shipping records through the OTLP logs pipeline shown here.


Step 3. Configure Exporter for CtrlB

Once your application is instrumented, configure the OTLP exporter to send telemetry data directly to CtrlB.

Set the following environment variables before starting your app:

OTEL_EXPORTER=otlp \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_SERVICE_NAME=<service_name> \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs \
OTEL_TRACES_EXPORTER=none \
OTEL_METRICS_EXPORTER=none \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>" \
opentelemetry-instrument python app.py

If you configured the logs pipeline programmatically (see Exporting Logs via the OTLP Logs Pipeline), these environment variables are still read by OTLPLogExporter() — no need to duplicate values in code.

Instrument all telemetry together

The block above sends logs only. To export traces, metrics, and logs from the same application, use the shared OTLP endpoint and headers instead of the signal-specific endpoint above:

OTEL_EXPORTER_OTLP_ENDPOINT=https://<INGESTION_HOST>/api/default \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>"

Step 4. Use OpenTelemetry Collector (Optional)

For production setups, it's recommended to send logs through an OpenTelemetry Collector before CtrlB. This enables buffering, batching, retries, and multi-sink routing.

See OpenTelemetry Collector for Logs for configuration details.


Environment Variables

Key environment variables for tuning the Python SDK:

VariablePurposeExample
OTEL_SERVICE_NAMELogical name for your service in CtrlBorder-service
OTEL_TRACES_EXPORTERExporter type (otlp, console, none)otlp
OTEL_EXPORTER_OTLP_ENDPOINTOTLP endpoint URLhttps://<INGESTION_HOST>/api/default
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTLog-specific OTLP endpointhttps://<INGESTION_HOST>/api/default/
<STREAM_NAME>/_otel/v1/logs
OTEL_EXPORTER_OTLP_PROTOCOLWire protocol (http/protobuf, grpc)http/protobuf
OTEL_EXPORTER_OTLP_HEADERSAuth and routing headersAuthorization=Basic <API_TOKEN>,
stream-name=<STREAM_NAME>
OTEL_METRICS_EXPORTERSet to none if you only need tracesnone
OTEL_LOGS_EXPORTERSet to none to suppress log export errorsnone
OTEL_PROPAGATORSContext propagation formattracecontext,baggage
OTEL_TRACES_SAMPLERSampling strategyparentbased_traceidratio
OTEL_TRACES_SAMPLER_ARGSampler argument (ratio 0.0–1.0)0.1
OTEL_RESOURCE_ATTRIBUTESAdditional resource attributesdeployment.environment=production,
service.version=1.2.0
OTEL_PYTHON_LOG_CORRELATIONInject trace context into stdlib loggingtrue
OTEL_PYTHON_EXCLUDED_URLSURL patterns to skip tracing (comma-separated)health,readyz
OTEL_PYTHON_DISABLED_INSTRUMENTATIONSInstrumentations to disable (comma-separated)urllib3,sqlite3

Full reference: OpenTelemetry SDK Configuration


Troubleshooting

gevent apps hang or produce no telemetry. gevent.monkey.patch_all() must be called before importing any opentelemetry module. Do not use opentelemetry-instrument with gevent — initialize the SDK in code. Use the HTTP/protobuf exporter, not gRPC. See gevent.

opentelemetry-bootstrap didn't install psycopg2 instrumentation. Bootstrap detects the psycopg2 package but may miss psycopg2-binary. Install opentelemetry-instrumentation-psycopg2 explicitly if you use the binary distribution.

Enable debug logging. Set OTEL_LOG_LEVEL=debug to surface SDK startup, signal export, and exporter diagnostic messages.

For more, see the official Python zero-code troubleshooting guide.


Flask

opentelemetry-instrumentation-flask instruments Flask's WSGI layer — capturing HTTP request/response spans, route attributes, and server metrics for every incoming request. It supports both auto-instrumentation (zero-code) and programmatic instrumentation.

For general Python OpenTelemetry setup (installation, database instrumentation, log correlation, and the OTLP logs pipeline), see the sections at the top of this page. For production deployment (Gunicorn, uWSGI, gevent, Celery, Docker, Kubernetes), see Deployment.

Install the Flask instrumentation with pip install opentelemetry-instrumentation-flask (or let opentelemetry-bootstrap -a install detect it). See Step 1. Install Dependencies.

Flask auto-instrumentation (zero-code)

The simplest approach — no code changes required. The opentelemetry-instrument CLI wraps your application and enables all installed instrumentations automatically.

Using the Flask development server:

opentelemetry-instrument flask run -p 8080 --no-reload

Or with a script entry point:

opentelemetry-instrument python app.py

Always use --no-reload with the Flask dev server. The reloader spawns a child process that bypasses the instrumentation agent. See Flask troubleshooting for details.

Flask programmatic instrumentation

If you need finer control, instrument Flask explicitly in your application code:

from flask import Flask
from opentelemetry.instrumentation.flask import FlaskInstrumentor

app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)

@app.route("/")
def hello():
return "Hello!"

instrument_app(app) patches a specific Flask app instance to create spans for every incoming request. You can also call FlaskInstrumentor().instrument() without an app reference — this monkey-patches the global flask.Flask class and is what the auto-instrumentation CLI uses internally.

Application factory pattern

Flask applications commonly use the factory pattern to create app instances. Call instrument_app() after registering blueprints but before returning the app:

from flask import Flask
from opentelemetry.instrumentation.flask import FlaskInstrumentor

def create_app(config_name="production"):
app = Flask(__name__)
app.config.from_object(config_name)

from .auth import auth_bp
from .api import api_bp
app.register_blueprint(auth_bp, url_prefix="/auth")
app.register_blueprint(api_bp, url_prefix="/api")

FlaskInstrumentor().instrument_app(app)
return app

Blueprints don't need separate instrumentation — the hooks are registered at the app level and apply to all routes regardless of which blueprint they belong to.

How Flask instrumentation works

Flask instrumentation wraps the app's WSGI callable, registering before_request and teardown_request hooks. For each incoming HTTP request:

  1. Creates a SpanKind.SERVER span
  2. Extracts incoming W3C TraceContext from request headers
  3. Sets HTTP semantic convention attributes from the WSGI environ
  4. Closes the span when the response is complete

Span names use Flask's URL rule pattern (e.g., GET /users/<id>), not the resolved path.

What Flask auto-captures

The Flask instrumentation automatically records the following for every HTTP request:

  • A SpanKind.SERVER span per incoming request
  • Span name derived from the Flask URL rule pattern (e.g., GET /users/<id>)
  • HTTP attributes: http.method, http.route, http.status_code, http.scheme, http.host, http.target, http.user_agent
  • Metrics: http.server.active_requests, http.server.duration
  • HTTP 5xx responses automatically set the span status to ERROR
  • Downstream HTTP calls (via requests, urllib3, httpx) become child spans when their instrumentation packages are installed

Flask configuration options

Exclude URLs

Skip instrumentation for specific endpoints (health checks, readiness probes, etc.):

Environment variable:

export OTEL_PYTHON_FLASK_EXCLUDED_URLS="health,ready,metrics"

Programmatic:

FlaskInstrumentor().instrument_app(app, excluded_urls="health,ready")

Request and response hooks

Hooks let you enrich spans with data from the request or response:

def request_hook(span, environ):
if span and span.is_recording():
span.set_attribute("custom.tenant_id", environ.get("HTTP_X_TENANT_ID", ""))

def response_hook(span, status, response_headers):
if span and span.is_recording():
span.set_attribute("custom.status", status)

FlaskInstrumentor().instrument_app(
app,
request_hook=request_hook,
response_hook=response_hook,
)

request_hook receives the WSGI environ dict, not flask.request. Neither flask.request nor flask.g are available inside hooks.

Capture HTTP headers

Record specific request and response headers as span attributes:

export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="X-Request-Id,X-Correlation-Id"
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="X-Response-Time"

Sanitize headers

Redact sensitive header values (matched headers are recorded as [REDACTED]):

export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS="Authorization,Cookie,Set-Cookie"

Commonly paired instrumentations

LibraryPackageWhat It Captures
requestsopentelemetry-instrumentation-requestsOutbound HTTP calls as child spans
urllib3opentelemetry-instrumentation-urllib3Low-level HTTP client spans
SQLAlchemyopentelemetry-instrumentation-sqlalchemyDB query spans
Jinja2opentelemetry-instrumentation-jinja2Template rendering spans
redisopentelemetry-instrumentation-redisRedis command spans
Celeryopentelemetry-instrumentation-celeryAsync task spans with context propagation

For database driver details, see Database Instrumentation. For async task tracing, see Celery.

Flask troubleshooting

  • --no-reload is mandatory with the Flask dev server. Flask's reloader starts a monitoring parent process that restarts the application in a child process. The opentelemetry-instrument agent attaches to the parent — not the child — so instrumentation is lost on reload. Always pass --no-reload:
opentelemetry-instrument flask run -p 8080 --no-reload
  • Avoid FLASK_DEBUG=1 in instrumented environments. Debug mode implicitly enables the reloader, causing the same issue. In instrumented environments, disable it explicitly:
FLASK_DEBUG=0 opentelemetry-instrument flask run -p 8080 --no-reload
  • Application factory produces no telemetry. instrument_app() was called on the wrong app instance, or after the app was already serving requests. Call it inside create_app() before return app. See Application factory pattern.

  • No spans with Gunicorn. Forking breaks the SDK's BatchSpanProcessor. Use the post_fork hook shown in the Deployment section, specifically Gunicorn.

  • Blueprint routes show <param> placeholders in span names. This is expected behavior. http.route uses Werkzeug's URL rule pattern (e.g., /users/<id>), not the resolved URL (/users/42). This keeps span cardinality low and groups routes correctly.


Django

opentelemetry-instrumentation-django instruments Django by injecting middleware into the MIDDLEWARE stack, automatically capturing HTTP request/response spans, URL route patterns, and request metrics. Both WSGI and ASGI deployments are supported.

For general Python OTEL setup (installation, database instrumentation, log correlation, and the OTLP logs pipeline), see the sections at the top of this page. For production deployment (Gunicorn, uWSGI, gevent, Celery, Docker, Kubernetes), see Deployment.

Install the Django instrumentation with pip install opentelemetry-instrumentation-django (and a DB driver instrumentation such as opentelemetry-instrumentation-psycopg2 for ORM query tracing), or let opentelemetry-bootstrap -a install detect them. See Step 1. Install Dependencies.

Django zero-code auto-instrumentation

The simplest approach — no code changes required. Set DJANGO_SETTINGS_MODULE and prefix your run command with opentelemetry-instrument:

DJANGO_SETTINGS_MODULE=myproject.settings \
opentelemetry-instrument python manage.py runserver --noreload

This automatically instruments Django and any other detected libraries (database drivers, HTTP clients, etc.).

Always use --noreload with Django's dev server. The auto-reloader spawns a child process that bypasses the instrumentation wrapper.

Django programmatic instrumentation

If you need finer control, instrument Django explicitly in code. The call to DjangoInstrumentor().instrument() must happen before Django processes any requests.

There are three common placement options:

1. manage.py — works for the dev server and management commands:

import os
import sys

def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()

from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

2. wsgi.py — works for Gunicorn and other WSGI servers:

import os
from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()

application = get_wsgi_application()

3. AppConfig.ready() — works in all deployment scenarios:

from django.apps import AppConfig

class MyAppConfig(AppConfig):
name = "myapp"

def ready(self):
from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()

Django ASGI deployment

For ASGI servers (Uvicorn, Daphne), instrument in asgi.py:

import os
from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()

application = get_asgi_application()

Run with auto-instrumentation:

opentelemetry-instrument uvicorn myproject.asgi:application

Or with Daphne:

opentelemetry-instrument daphne myproject.asgi:application

DjangoInstrumentor handles both WSGI and ASGI transparently — Django 3.0+ middleware is protocol-agnostic, so the same _DjangoMiddleware works regardless of the server interface.

Django Channels (WebSockets)

DjangoInstrumentor only covers the HTTP request path. For WebSocket consumers via Django Channels, wrap the ASGI routing with OpenTelemetryMiddleware:

from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware

application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": OpenTelemetryMiddleware(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
})

For HTTP, use DjangoInstrumentor only — do not also wrap HTTP with OpenTelemetryMiddleware, or you will get duplicate spans.

For manual span creation in WebSocket consumers:

from channels.generic.websocket import AsyncWebsocketConsumer
from opentelemetry import trace

tracer = trace.get_tracer(__name__)

class ChatConsumer(AsyncWebsocketConsumer):
async def receive(self, text_data):
with tracer.start_as_current_span("websocket.receive") as span:
span.set_attribute("websocket.message_size", len(text_data))
...

opentelemetry-instrumentation-asgi must be installed separately: pip install opentelemetry-instrumentation-asgi.

How Django instrumentation works

DjangoInstrumentor inserts _DjangoMiddleware at position 0 in Django's MIDDLEWARE list. This middleware:

  1. Creates a SpanKind.SERVER span when a request enters Django
  2. Extracts incoming trace context from request headers (W3C TraceContext / B3)
  3. Attaches HTTP semantic attributes (method, route, status code)
  4. Closes the span when the response is sent

Position 0 means the OTEL middleware wraps all other Django middleware, capturing the full request lifecycle including authentication, session handling, and CSRF. You can control placement via OTEL_PYTHON_DJANGO_MIDDLEWARE_POSITION.

The same middleware works for ASGI because Django 3.0+ middleware is protocol-agnostic — _DjangoMiddleware detects whether it received an ASGI scope or a WSGI environ and behaves accordingly.

What Django auto-captures

The Django instrumentation automatically records the following without any code changes:

  • SpanKind.SERVER span per HTTP request
  • Span name derived from the Django URL route pattern (e.g., GET /api/users/<int:id>)
  • HTTP attributes: http.method, http.route, http.status_code, http.scheme, http.host, http.target
  • Metrics: http.server.active_requests, http.server.duration
  • Database queries appear as child spans only when DB driver instrumentation is installed (see Database Instrumentation)
  • Error tracking: 5xx responses automatically set span status to ERROR

Django configuration options

Disable Django instrumentation

OTEL_PYTHON_DJANGO_INSTRUMENT=false

Useful when you want OTEL active for other libraries but need to temporarily disable Django's middleware injection.

Exclude URLs from tracing

OTEL_PYTHON_DJANGO_EXCLUDED_URLS="admin/.*,static/.*,health,ready"

Comma-separated list of URL patterns (regex supported). Matching requests won't generate spans — useful for admin panels, static assets, and health checks.

Traced request attributes

OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS="content_type,user_agent"

Comma-separated list of django.http.HttpRequest attributes to capture as span attributes.

Middleware positioning

OTEL_PYTHON_DJANGO_MIDDLEWARE_POSITION=1

Default is 0 (first middleware). Set to 1 to insert after SecurityMiddleware — this avoids DisallowedHost exceptions from the OTEL middleware calling get_host() before host validation. Trade-off: SecurityMiddleware processing time is not included in span duration.

Django request and response hooks

Hooks let you enrich spans with application-specific data. Important caveat: request_hook fires before Django's middleware stack runs, so request.user, request.session, and other middleware-injected attributes are not available. Use response_hook for middleware-dependent attributes:

def response_hook(span, request, response):
if hasattr(request, "user") and request.user.is_authenticated:
span.set_attribute("enduser.id", request.user.pk)

DjangoInstrumentor().instrument(response_hook=response_hook)

A request_hook is still useful for data available directly on the raw request:

def request_hook(span, request):
span.set_attribute("http.request_content_length", request.META.get("CONTENT_LENGTH", 0))

DjangoInstrumentor().instrument(request_hook=request_hook)

Capture HTTP headers (Django)

OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="content-type,x-request-id"
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="content-type,x-request-id"
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS="Authorization,Cookie,Set-Cookie"

Sanitized headers are recorded as [REDACTED].

Deploying Django

Django apps typically run behind Gunicorn or uWSGI (WSGI) or Uvicorn/Daphne (ASGI). Each server has fork-safety implications for the OTEL SDK.

Gunicorn (WSGI) — initialize the SDK in a post_fork hook so each worker gets its own TracerProvider:

# gunicorn.conf.py
def post_fork(server, worker):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
import os

resource = Resource.create({SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "django-app")})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

uWSGI — requires lazy-apps = true (non-negotiable). Without it, uWSGI loads the application before forking workers, and the SDK's internal state is not properly shared across processes.

ASGI multi-worker — use Gunicorn with Uvicorn workers:

gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker --workers 4

DJANGO_SETTINGS_MODULE must be set before opentelemetry-instrument runs.

For the full story on forking, gevent, Docker, and Kubernetes, see Deployment.

Django background tasks with Celery

Django + Celery is the most common async task pairing. Install opentelemetry-instrumentation-celery and follow the pattern in Celery.

Django-specific consideration: if Celery tasks use the Django ORM, django.setup() must be called before DjangoInstrumentor().instrument() in the worker_process_init handler:

from celery.signals import worker_process_init

@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
import django
django.setup()

from opentelemetry.instrumentation.django import DjangoInstrumentor
from opentelemetry.instrumentation.celery import CeleryInstrumentor
DjangoInstrumentor().instrument()
CeleryInstrumentor().instrument()

This ensures Django's app registry is fully loaded before instrumentation patches the ORM.

Django troubleshooting

  • --noreload is mandatory. Django's auto-reloader spawns a child process that does not inherit the opentelemetry-instrument wrapper, so telemetry silently stops being generated.

  • DJANGO_SETTINGS_MODULE must be set. Without it, auto-instrumentation cannot initialize Django and middleware injection fails.

  • request_hook has no request.user. The hook fires before Django's authentication middleware runs. Use response_hook to access middleware-dependent attributes.

  • Middleware ordering conflicts. Set OTEL_PYTHON_DJANGO_MIDDLEWARE_POSITION=1 to insert the OTEL middleware after SecurityMiddleware. This prevents DisallowedHost exceptions from the OTEL middleware calling get_host() before host validation.

  • WebSocket connections have no spans. DjangoInstrumentor only covers HTTP. For Django Channels WebSocket tracing, see Django Channels (WebSockets).

  • No spans with Gunicorn or uWSGI. Pre-fork servers require SDK initialization in each worker process. See Deploying Django and Gunicorn.

  • psycopg2-binary not detected. opentelemetry-bootstrap targets the psycopg2 package. If only psycopg2-binary is installed, install opentelemetry-instrumentation-psycopg2 explicitly. See Database Instrumentation.


Deployment

Python OpenTelemetry works cleanly in local development, but production deployments introduce process-forking, worker pools, and async event loops that can silently break instrumentation. This section covers the deployment patterns needed to make OTEL work reliably with Gunicorn, uWSGI, gevent, ASGI servers, Celery, Docker, and Kubernetes — all exporting logs to CtrlB.

For the general SDK setup (installation, database instrumentation, log correlation, and the OTLP logs pipeline), see the sections at the top of this page. For framework-specific guidance, see Flask and Django.

Gunicorn

Gunicorn uses a pre-fork model: the master process starts, then forks worker processes. If the OTEL SDK is initialized in the master (e.g., via opentelemetry-instrument), the BatchSpanProcessor background thread does not survive the fork. Workers inherit a dead processor and silently drop all spans.

Solution: initialize the SDK in each worker using post_fork.

import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME

bind = "0.0.0.0:8000"
workers = 4

def post_fork(server, worker):
resource = Resource.create({
SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "my-service"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

def worker_exit(server, worker):
provider = trace.get_tracer_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()

Save this as gunicorn.conf.py and run:

gunicorn -c gunicorn.conf.py myapp.wsgi:application

Worker class compatibility:

Worker ClassRecommendation
sync (default)Use post_fork hook as shown above
gthreadUse post_fork hook; SDK is thread-safe once initialized in the worker
geventSee gevent; import OTEL after monkey.patch_all()
uvicorn.workers.UvicornWorkerRecommended for ASGI apps; full OTEL support with auto-instrumentation

uWSGI

uWSGI also forks workers, so the same problem applies. Two mutually exclusive solutions:

Option 1: lazy-apps mode — loads the application inside each worker after forking:

[uwsgi]
module = myapp.wsgi:application
master = true
processes = 4
lazy-apps = true
enable-threads = true

Option 2: @postfork decorator — initializes the SDK after the fork:

import uwsgidecorators
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

@uwsgidecorators.postfork
def init_tracing():
provider = TracerProvider(resource=Resource.create({}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

enable-threads = true is required in your uWSGI config. Without it, BatchSpanProcessor cannot start its background export thread and spans are silently dropped.

gevent

gevent's monkey-patching replaces stdlib threading and socket modules with cooperative greenlet versions. The OTEL SDK must be imported after monkey.patch_all() to pick up the patched modules.

Rules for gevent:

  1. Call gevent.monkey.patch_all() before importing any opentelemetry package
  2. Do not use the opentelemetry-instrument CLI — it imports OTEL before your monkey-patching runs
  3. Use the HTTP/protobuf exporter (opentelemetry-exporter-otlp-proto-http), not gRPC — the gRPC transport has known compatibility issues with gevent
  4. Initialize the SDK programmatically

Gunicorn + gevent workers:

import gevent.monkey
gevent.monkey.patch_all()

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
import os

bind = "0.0.0.0:8000"
workers = 4
worker_class = "gevent"

def post_fork(server, worker):
resource = Resource.create({
SERVICE_NAME: os.getenv("OTEL_SERVICE_NAME", "my-service"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

ASGI Servers (Uvicorn, Hypercorn, Daphne)

Single-worker Uvicorn:

opentelemetry-instrument uvicorn app:app --host 0.0.0.0 --port 8000

Multi-worker Uvicorn (via Gunicorn):

Use Gunicorn with UvicornWorker and the post_fork hook described in Gunicorn:

gunicorn -c gunicorn.conf.py -k uvicorn.workers.UvicornWorker app:app

Hypercorn uses spawn (not fork) for its workers. The opentelemetry-instrument CLI does not work with Hypercorn's multiprocess model. Use programmatic SDK initialization in your ASGI app's startup event instead.

Never use --reload with any ASGI server in instrumented environments. The reloader spawns a new process that bypasses the instrumentation wrapper.

Celery

Celery uses a prefork worker pool by default, so the OTEL SDK must be initialized inside each worker process — not in the master. This is the same fork-safety problem as Gunicorn, solved with the worker_process_init signal.

Install the instrumentation package:

pip install opentelemetry-instrumentation-celery

Initialize tracing in the worker_process_init signal:

from celery.signals import worker_process_init

@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.celery import CeleryInstrumentor

resource = Resource.create({})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
CeleryInstrumentor().instrument()

Imports are inside the signal handler intentionally — they must run after the fork, not in the master process.

Context propagation: The Celery instrumentation automatically injects W3C TraceContext headers into task message headers. When a web request enqueues a Celery task, and that task enqueues a sub-task, the full trace chain is preserved:

HTTP request → celery.apply_async → worker: run_task → celery.apply_async → worker: run_subtask

Each task execution appears as a child span of the caller, giving you end-to-end visibility in CtrlB.

Docker

Install dependencies and run bootstrap during the build, then use opentelemetry-instrument as the entrypoint.

Multi-stage build example:

FROM python:3.12-slim AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && \
opentelemetry-bootstrap -a install

FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .

CMD ["opentelemetry-instrument", "python", "app.py"]

Pass CtrlB configuration as environment variables at runtime:

docker run \
-e OTEL_SERVICE_NAME=<service_name> \
-e OTEL_EXPORTER_OTLP_ENDPOINT=https://<INGESTION_HOST>/api/default \
-e OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs \
-e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
-e OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>" \
myapp:latest

Kubernetes

Pass OTEL environment variables through a ConfigMap or Secret:

apiVersion: v1
kind: ConfigMap
metadata:
name: otel-config
data:
OTEL_SERVICE_NAME: "my-service"
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
OTEL_EXPORTER_OTLP_ENDPOINT: "https://<INGESTION_HOST>/api/default"
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs"
apiVersion: v1
kind: Secret
metadata:
name: otel-secret
type: Opaque
stringData:
OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>"

Reference them in your Deployment:

containers:
- name: myapp
image: myapp:latest
envFrom:
- configMapRef:
name: otel-config
- secretRef:
name: otel-secret
env:
- name: OTEL_RESOURCE_ATTRIBUTES
value: "k8s.namespace.name=$(NAMESPACE),k8s.pod.name=$(POD_NAME)"
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name

Sidecar collector pattern: Deploy the OpenTelemetry Collector as a sidecar container in each pod. Applications send telemetry to localhost:4318, and the collector forwards to CtrlB. See OpenTelemetry Collector for Logs.

OTEL Operator: The OpenTelemetry Operator for Kubernetes can auto-inject the Python SDK into pods using an Instrumentation CRD, eliminating the need to modify your Docker image.