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
| Signal | Python SDK status | Notes |
|---|---|---|
| Logs | In development | Functional 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._logsandopentelemetry.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:
LogDatawas removed and severalLog*classes were renamed toLogRecord*. The bridge from stdlibloggingto OTLP export isLoggingHandlerinopentelemetry.sdk._logs. Pin youropentelemetry-sdkversion inrequirements.txtand 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
- Python 3.9+
- pip
- CtrlB OTLP (ingestion host, auth, stream, service name): See Prerequisites
Step 1. Install Dependencies
You can install OpenTelemetry in two main ways—depending on whether you want all instrumentations or just specific ones.
A: Install all instrumentation libraries (recommended for getting started)
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-bootstrapinspects your installedsite-packagesand installs the corresponding instrumentation libraries (e.g.,opentelemetry-instrumentation-requestsifrequestsis present). Always run it after installing your application's dependencies. Re-run it whenever you add a new dependency.
B: Install specific instrumentation libraries (recommended for production)
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-instrumentdiscovers 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:
- Discovers all registered entry points in the current environment
- Calls each instrumentor's
instrument()method, which patches the target library's module-level objects - 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.
| Library | Instrumentation Package | Notes |
|---|---|---|
| psycopg2 | opentelemetry-instrumentation-psycopg2 | psycopg2-binary may not be auto-detected by bootstrap; install explicitly |
| psycopg (v3) | opentelemetry-instrumentation-psycopg | Separate from psycopg2; supports async |
| asyncpg | opentelemetry-instrumentation-asyncpg | For async PostgreSQL |
| SQLAlchemy | opentelemetry-instrumentation-sqlalchemy | Pass engine= to instrument() for targeted instrumentation |
| pymysql | opentelemetry-instrumentation-pymysql | — |
| mysqlclient | opentelemetry-instrumentation-mysqlclient | — |
| redis | opentelemetry-instrumentation-redis | — |
| sqlite3 | opentelemetry-instrumentation-sqlite3 | stdlib; 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
loggingfilters. For structlog, add a custom processor that reads fromtrace.get_current_span(). For loguru, uselogger.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 youropentelemetry-sdkversion and read the changelog before upgrading. - Root-logger reach. Because records flow through the standard
loggingmodule, 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/otelSpanIDinto stdlib log format strings for scrape-and-forward setups — viaOTEL_PYTHON_LOG_CORRELATION=trueor, programmatically,LoggingInstrumentor().instrument()fromopentelemetry-instrumentation-logging. That package does not export logs.LoggingHandleris 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.
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:
| Variable | Purpose | Example |
|---|---|---|
OTEL_SERVICE_NAME | Logical name for your service in CtrlB | order-service |
OTEL_TRACES_EXPORTER | Exporter type (otlp, console, none) | otlp |
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP endpoint URL | https://<INGESTION_HOST>/api/default |
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT | Log-specific OTLP endpoint | https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs |
OTEL_EXPORTER_OTLP_PROTOCOL | Wire protocol (http/protobuf, grpc) | http/protobuf |
OTEL_EXPORTER_OTLP_HEADERS | Auth and routing headers | Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME> |
OTEL_METRICS_EXPORTER | Set to none if you only need traces | none |
OTEL_LOGS_EXPORTER | Set to none to suppress log export errors | none |
OTEL_PROPAGATORS | Context propagation format | tracecontext,baggage |
OTEL_TRACES_SAMPLER | Sampling strategy | parentbased_traceidratio |
OTEL_TRACES_SAMPLER_ARG | Sampler argument (ratio 0.0–1.0) | 0.1 |
OTEL_RESOURCE_ATTRIBUTES | Additional resource attributes | deployment.environment=production,service.version=1.2.0 |
OTEL_PYTHON_LOG_CORRELATION | Inject trace context into stdlib logging | true |
OTEL_PYTHON_EXCLUDED_URLS | URL patterns to skip tracing (comma-separated) | health,readyz |
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS | Instrumentations 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 letopentelemetry-bootstrap -a installdetect 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-reloadwith 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:
- Creates a
SpanKind.SERVERspan - Extracts incoming W3C TraceContext from request headers
- Sets HTTP semantic convention attributes from the WSGI
environ - 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.SERVERspan 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_hookreceives the WSGIenvirondict, notflask.request. Neitherflask.requestnorflask.gare 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
| Library | Package | What It Captures |
|---|---|---|
| requests | opentelemetry-instrumentation-requests | Outbound HTTP calls as child spans |
| urllib3 | opentelemetry-instrumentation-urllib3 | Low-level HTTP client spans |
| SQLAlchemy | opentelemetry-instrumentation-sqlalchemy | DB query spans |
| Jinja2 | opentelemetry-instrumentation-jinja2 | Template rendering spans |
| redis | opentelemetry-instrumentation-redis | Redis command spans |
| Celery | opentelemetry-instrumentation-celery | Async task spans with context propagation |
For database driver details, see Database Instrumentation. For async task tracing, see Celery.
Flask troubleshooting
--no-reloadis mandatory with the Flask dev server. Flask's reloader starts a monitoring parent process that restarts the application in a child process. Theopentelemetry-instrumentagent 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=1in 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 insidecreate_app()beforereturn app. See Application factory pattern. -
No spans with Gunicorn. Forking breaks the SDK's
BatchSpanProcessor. Use thepost_forkhook shown in the Deployment section, specifically Gunicorn. -
Blueprint routes show
<param>placeholders in span names. This is expected behavior.http.routeuses 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 asopentelemetry-instrumentation-psycopg2for ORM query tracing), or letopentelemetry-bootstrap -a installdetect 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
--noreloadwith 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
DjangoInstrumentoronly — do not also wrap HTTP withOpenTelemetryMiddleware, 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-asgimust 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:
- Creates a
SpanKind.SERVERspan when a request enters Django - Extracts incoming trace context from request headers (W3C TraceContext / B3)
- Attaches HTTP semantic attributes (method, route, status code)
- 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.SERVERspan 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_MODULEmust be set beforeopentelemetry-instrumentruns.
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
-
--noreloadis mandatory. Django's auto-reloader spawns a child process that does not inherit theopentelemetry-instrumentwrapper, so telemetry silently stops being generated. -
DJANGO_SETTINGS_MODULEmust be set. Without it, auto-instrumentation cannot initialize Django and middleware injection fails. -
request_hookhas norequest.user. The hook fires before Django's authentication middleware runs. Useresponse_hookto access middleware-dependent attributes. -
Middleware ordering conflicts. Set
OTEL_PYTHON_DJANGO_MIDDLEWARE_POSITION=1to insert the OTEL middleware afterSecurityMiddleware. This preventsDisallowedHostexceptions from the OTEL middleware callingget_host()before host validation. -
WebSocket connections have no spans.
DjangoInstrumentoronly 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-binarynot detected.opentelemetry-bootstraptargets thepsycopg2package. If onlypsycopg2-binaryis installed, installopentelemetry-instrumentation-psycopg2explicitly. 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 Class | Recommendation |
|---|---|
sync (default) | Use post_fork hook as shown above |
gthread | Use post_fork hook; SDK is thread-safe once initialized in the worker |
gevent | See gevent; import OTEL after monkey.patch_all() |
uvicorn.workers.UvicornWorker | Recommended 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 = trueis required in your uWSGI config. Without it,BatchSpanProcessorcannot 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:
- Call
gevent.monkey.patch_all()before importing anyopentelemetrypackage - Do not use the
opentelemetry-instrumentCLI — it imports OTEL before your monkey-patching runs - Use the HTTP/protobuf exporter (
opentelemetry-exporter-otlp-proto-http), not gRPC — the gRPC transport has known compatibility issues with gevent - 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
--reloadwith 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.