Zero-Code Instrumentation with opentelemetry-instrument
The fastest way to get traces from an existing Python service is to change nothing in its code: install the OpenTelemetry distro and the instrumentation packages for the libraries it uses, and prefix its start command with opentelemetry-instrument. The launcher configures the SDK from environment variables and patches each supported library before the application imports it, so the web framework, database driver and HTTP clients produce spans immediately. This page covers what the launcher actually does, configuring it by environment, the prefork problem that catches most production deployments, and where automatic instrumentation needs a manual complement. It is a task article under OpenTelemetry SDK setup, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "opentelemetry-distro>=0.48b0,<1.0.0" \
"opentelemetry-exporter-otlp>=1.27.0,<2.0.0"
# add instrumentation packages matching the libraries already installed
opentelemetry-bootstrap -a install
Implementation
Step 1 — Install the distro and let bootstrap choose instrumentations. The distro provides the launcher and sensible defaults. opentelemetry-bootstrap inspects the installed packages and installs the matching instrumentation for each — Flask, Django, FastAPI, psycopg, SQLAlchemy, requests, httpx, redis and many more. Running it in the image build, after the application's dependencies are installed, keeps the two in step.
Step 2 — Wrap the start command. The service's command is unchanged apart from the prefix. The launcher configures everything, then hands over to the original command.
opentelemetry-instrument uvicorn myservice.app:app --host 0.0.0.0 --port 8000
Step 3 — Configure through the environment. Everything the launcher needs comes from environment variables — the same set every OpenTelemetry SDK understands, so the configuration reads the same across languages — which makes the same image correct in every environment and keeps telemetry configuration in the deployment rather than in code.
export OTEL_SERVICE_NAME=checkout
export OTEL_RESOURCE_ATTRIBUTES="service.version=2026.09.18,deployment.environment=production"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.10
export OTEL_PROPAGATORS=tracecontext,baggage
export OTEL_PYTHON_LOG_CORRELATION=true # trace ids on stdlib log records
export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=redis # too chatty for this service
Expected Output: spans from libraries the application never mentions OpenTelemetry in.
GET /orders/{order_id} SERVER 212 ms
SELECT orders CLIENT 41 ms db.system=postgresql
GET https://pricing.internal/quote CLIENT 138 ms http.response.status_code=200
Step 4 — Handle prefork servers explicitly. Under Gunicorn the launcher configures the SDK in the master. The master then forks, and the batch span processor's export thread exists only in the master — workers create spans into a queue nothing drains. The reliable fix is to re-initialise the tracer provider in a post-fork hook, which also gives each worker its own instance identifier. The same concern and fix appear in Gunicorn and Uvicorn worker logging.
# 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.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
provider = TracerProvider(resource=Resource.create()) # env-driven, per worker
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
Step 5 — Add manual spans where only the application knows the meaning. Automatic spans describe library calls: a SELECT, a POST. They cannot describe what those calls were for. A few manual spans — around "apply discount rules", "reserve stock", "calculate shipping" — turn a trace from a list of queries into a description of what the service did, and they are cheap to add because the SDK is already configured.
from opentelemetry import trace
tracer = trace.get_tracer("checkout")
def place_order(cart):
with tracer.start_as_current_span("place order") as span:
span.set_attribute("order.item_count", len(cart.items))
with tracer.start_as_current_span("reserve stock"):
reserve(cart)
with tracer.start_as_current_span("charge payment"):
charge(cart)
Where zero-code instrumentation stops
The launcher is an excellent starting point and has limits worth knowing before relying on it entirely.
It only covers libraries with instrumentation packages. Most popular frameworks, drivers and clients are covered. An internal HTTP client wrapper, a less common database driver, or a library that talks to its backend over a custom protocol produces no spans. The trace then has gaps exactly where those libraries were used.
It can be too broad. Some instrumentations produce a span per call, and some calls happen thousands of times per request — cache lookups, fine-grained ORM operations. The resulting traces are large and expensive, and the useful spans are buried. Disabling specific instrumentations, or tuning them through their own options, is part of adopting the launcher in production rather than an afterthought.
It configures once, at start. Anything that needs to differ per request, per tenant or per operation — sampling for a debug flag, extra attributes from application context — needs a span processor or code, not environment variables. The patterns in using baggage for tenant and feature context are one way to add that without touching business logic.
It hides the configuration. A service configured entirely by the launcher has no code that says what its telemetry setup is. That is convenient and occasionally confusing: an engineer debugging missing spans may not realise the SDK was configured at all, or by what. Documenting the environment variables alongside the service, or moving to an explicit configuration module once the setup stabilises, keeps it understandable. Debugging missing spans in Python covers diagnosing the cases where something went wrong.
Many teams follow a natural progression: start with the launcher for immediate coverage, add manual spans for business operations, and eventually move the SDK configuration into code where it can be tested and reviewed, keeping the automatic library instrumentation throughout.
Rolling it out across a fleet
The launcher's appeal is strongest at fleet scale, where it can instrument dozens of services without dozens of code changes. A few practices make that rollout go smoothly.
Put it in the base image. A shared base image that installs the distro, runs bootstrap after the service's dependencies, and sets the start command's prefix means every service built on it is instrumented by default. Services opt out rather than in, which is the direction that produces consistent coverage.
Set the fleet-wide environment in one place. Exporter endpoint, propagators, sampler and log correlation are the same for most services and belong in a shared deployment template. Only the service name and version differ per service, and those come from the build. Keeping the shared settings centralised means a change to the collector endpoint or the sample rate is one edit, not forty.
Start with a low sample rate and raise it deliberately. Automatic instrumentation of an unfamiliar service can produce more spans per request than anyone expects, particularly with ORM and cache instrumentation enabled. A rollout at a few percent, followed by a look at spans per request per service as described in estimating telemetry volume from a Python service, shows which services need instrumentations disabled before the rate is raised.
Verify each service once. A single request through each service after rollout, checked for a connected trace with the expected library spans, catches the prefork problem, missing instrumentation packages and propagation gaps between services. Services that fail the check are usually few and each has one of those three causes.
Configuration options
| Variable | Example | Effect |
|---|---|---|
OTEL_SERVICE_NAME |
checkout |
resource identity |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 |
where spans go |
OTEL_TRACES_SAMPLER |
parentbased_traceidratio |
head sampling strategy |
OTEL_TRACES_SAMPLER_ARG |
0.10 |
the ratio |
OTEL_PROPAGATORS |
tracecontext,baggage |
cross-service context |
OTEL_PYTHON_LOG_CORRELATION |
true |
trace ids on log records |
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS |
redis,sqlite3 |
turn off noisy ones |
OTEL_PYTHON_EXCLUDED_URLS |
healthz,readyz |
skip health checks |
Verification
Confirm which instrumentations loaded and that workers export.
OTEL_TRACES_EXPORTER=console opentelemetry-instrument python -c "
import requests; requests.get('https://example.org')" 2>&1 | grep '"name"'
Expected Output: a span from a library the command never instrumented explicitly.
"name": "GET",
Common mistakes
Libraries imported before the launcher runs. Error signature: some libraries traced and others not. Root cause: a module imported during an earlier import hook or a site customisation. Remediation: the launcher must wrap the process start.
Relying on the launcher under Gunicorn. Error signature: no spans from workers in production. Root cause: the export thread exists only in the master. Remediation: re-initialise the provider in post_fork.
Instrumentation packages out of step with libraries. Error signature: an instrumentation that silently does nothing after an upgrade. Root cause: bootstrap run against a different dependency set. Remediation: run bootstrap in the image build after dependencies are installed.
Health checks traced at full rate. Error signature: a large share of stored traces are probes against health endpoints. Root cause: framework instrumentation traces every route by default. Remediation: exclude health and readiness paths with the excluded-URLs setting, so the sample budget is spent on real traffic.
Every instrumentation enabled. Error signature: huge traces dominated by cache or ORM spans. Root cause: accepting all defaults. Remediation: disable or tune the noisy ones.
No manual spans at all. Error signature: traces that list queries but do not say what the request was doing. Root cause: treating automatic instrumentation as complete. Remediation: a few manual spans around business operations.
Frequently Asked Questions
What does opentelemetry-instrument actually do?
It runs before your application: it configures the SDK from environment variables — providers, exporters, sampler, propagators — and loads every installed instrumentation package, which patches its target library. Then it runs your command as normal. When your code imports Flask or psycopg, it gets the patched version.
Do I need to change application code?
No, for the libraries covered by instrumentation packages. You change the start command and the environment. Business-level spans — naming what the application is doing rather than which library call it made — still need code.
Does it work with Gunicorn?
It configures the SDK in the master before forking, and the export threads do not survive the fork, so spans created in workers may never be exported. The standard fix is to re-initialise the SDK in a post-fork hook rather than relying on the launcher alone.
How do I disable one instrumentation?
Set OTEL_PYTHON_DISABLED_INSTRUMENTATIONS to a comma-separated list of instrumentation names. Useful when an instrumentation is too noisy — a cache client producing thousands of spans per request, for example.