Upcoming Webinar:

Getting Started with OpenObserve

July 16, 2026
11:00 AM ET

OpenTelemetry for Python: Traces, Metrics & Logs with OpenObserve

Instrument Python apps with OpenTelemetry — zero-code and manual SDK setup for traces, metrics, and logs, exported to OpenObserve via OTLP.

OpenTelemetry guide

OpenTelemetry is the CNCF-standard toolkit for generating traces, metrics, and logs from your applications, and its Python implementation is one of the most mature in the ecosystem. This guide covers everything you need to instrument a Python service — from the zero-code opentelemetry-instrument launcher to full manual SDK control — and ship the resulting telemetry to OpenObserve over OTLP. If you are new to the project itself, start with What is OpenTelemetry? for the conceptual background; this page is about getting Python data flowing.

Why OpenTelemetry for Python

Python observability used to mean stitching together a tracing agent from one vendor, a StatsD client for metrics, and a log shipper on the side — three formats, three configuration surfaces, three lock-ins. OpenTelemetry replaces all of that with a single, vendor-neutral API and SDK. You instrument once, and the OTLP wire protocol carries all three signals to any compatible backend.

The Python implementation is production-ready for traces and metrics. Logs work end to end but are still formally maturing, which affects which import paths you use (more on that below).

SignalPython SDK statusNotes
TracesStableProduction-ready; API is frozen
MetricsStableProduction-ready; API is frozen
LogsIn developmentFunctional and widely used, but 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 signal that the surface may change.
  • Recent SDK releases have reshaped the logs pipeline as part of stabilization (for example, LogData was removed and several classes were renamed from Log* to LogRecord*, and the SDK’s built-in LoggingHandler was deprecated in favor of the one in opentelemetry-instrumentation-logging). Pin your opentelemetry-sdk version in requirements.txt and read the changelog before upgrading if you depend on logs.

None of this should stop you from using OTel 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.10 or later. Recent OpenTelemetry Python releases require 3.10+.
  • pip and a virtual environment. All packages install from PyPI.
  • An OpenObserve instance. Either OpenObserve Cloud (free tier available) or self-hosted — a single binary or container listening on port 5080:
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
  • Your OTLP credentials. OpenObserve authenticates OTLP requests with HTTP Basic auth. Build the token from your login email and password (Cloud users can copy a ready-made token from the Ingestion section of the UI):
echo -n 'root@example.com:Complexpass#123' | base64
#=> cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=

You will use this value as Authorization: Basic <token> throughout the guide.

Zero-code instrumentation with opentelemetry-instrument

Python has first-class automatic instrumentation: a launcher that patches supported libraries at startup so you get traces (and optionally metrics and logs) without touching application code. This is the fastest path to useful telemetry and works with any framework — Flask, FastAPI, Django, aiohttp, Celery, plain WSGI, anything with an instrumentation package.

Install the distro and the OTLP exporter, then let opentelemetry-bootstrap detect what is in your environment and install the matching instrumentation libraries:

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

opentelemetry-bootstrap -a install scans your installed packages and pulls in the corresponding opentelemetry-instrumentation-* packages — for example, if requests is installed it adds opentelemetry-instrumentation-requests. Re-run it whenever your dependencies change.

Now run your application through the launcher with OTLP pointed at OpenObserve:

OTEL_SERVICE_NAME=checkout-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5080/api/default \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=" \
opentelemetry-instrument python app.py

Notes on this invocation:

  • OTEL_EXPORTER_OTLP_ENDPOINT is the base endpoint. With http/protobuf, the exporter appends /v1/traces, /v1/metrics, and /v1/logs automatically — so the value is your OpenObserve API base including the organization (/api/default here), with no trailing slash.
  • OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf matters. The distro defaults to gRPC on port 4317; OpenObserve’s primary OTLP endpoint is HTTP on port 5080.
  • The %20 in the headers value is deliberate — the env var format requires URL-encoded values, so the space between Basic and the token must be %20.
  • Log export via the launcher is opt-in while the signal stabilizes. opentelemetry-bootstrap -a install pulls in opentelemetry-instrumentation-logging, whose LoggingHandler is auto-attached to the root logger by default (OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION defaults to true). OTEL_LOGS_EXPORTER is already set to otlp by opentelemetry-distro via os.environ.setdefault, so you only need to ensure the instrumentation-logging package is installed to ship logging output as OTLP logs. (Avoid OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true — it activates the SDK’s deprecated LoggingHandler path, and opentelemetry-instrumentation-logging explicitly skips installing its handler and warns when that var is set.)

That is a complete production setup for many services: automatic HTTP server spans, client spans for outbound calls, database spans, and runtime metrics, all with zero code changes. For a worked end-to-end example against a real app, see monitoring a FastAPI application with OpenTelemetry and OpenObserve.

Prefer flags to env vars? The launcher accepts equivalents directly:

opentelemetry-instrument \
  --service_name checkout-service \
  --exporter_otlp_endpoint http://localhost:5080/api/default \
  --exporter_otlp_protocol http/protobuf \
  python app.py

Manual instrumentation

Zero-code gets you framework-level telemetry; manual instrumentation is how you capture business operations — the span around a payment authorization, the counter of orders placed, the log line that carries your trace context. The two approaches compose: run under opentelemetry-instrument and add custom spans, or wire the SDK yourself for full control. The examples below are self-contained and use the plain opentelemetry-sdk with the OTLP/HTTP exporter:

pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

Traces

Configure a TracerProvider once at process startup, attach a BatchSpanProcessor wrapping the OTLP exporter, then create spans anywhere via the API:

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

resource = Resource.create({
    "service.name": "checkout-service",
    "deployment.environment": "production",
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(
            endpoint="http://localhost:5080/api/default/v1/traces",
            headers={"Authorization": "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="},
        )
    )
)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("checkout.payments")

def charge_card(order_id: str, amount_cents: int) -> None:
    with tracer.start_as_current_span("charge_card") as span:
        span.set_attribute("order.id", order_id)
        span.set_attribute("payment.amount_cents", amount_cents)
        try:
            process_payment(order_id, amount_cents)  # your business logic
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(trace.StatusCode.ERROR, str(exc))
            raise

if __name__ == "__main__":
    charge_card("ord-1234", 4999)
    provider.shutdown()  # flush pending spans before exit

Two details worth internalizing:

  • When you construct the exporter in code (rather than via env vars), you pass the full signal path/v1/traces — because there is nothing to append it for you.
  • record_exception plus set_status(StatusCode.ERROR) is the idiomatic way to make failures visible and filterable in your backend; error handling with the OpenTelemetry Python SDK covers the patterns in depth, including exception events and escaped-exception semantics.

In real services, prefer configuring the exporter through the standard OTEL_EXPORTER_OTLP_* environment variables (shown in the next section) and constructing OTLPSpanExporter() with no arguments — the exporter reads them automatically, which keeps credentials out of code.

Metrics

The metrics SDK mirrors the tracing setup: a MeterProvider with a PeriodicExportingMetricReader that pushes to OTLP on an interval (60 seconds by default):

import time

from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "checkout-service"})

reader = PeriodicExportingMetricReader(
    OTLPMetricExporter(
        endpoint="http://localhost:5080/api/default/v1/metrics",
        headers={"Authorization": "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="},
    ),
    export_interval_millis=15000,
)
provider = MeterProvider(resource=resource, metric_readers=[reader])
metrics.set_meter_provider(provider)

meter = metrics.get_meter("checkout.metrics")

orders_placed = meter.create_counter(
    "orders.placed",
    unit="{order}",
    description="Number of orders placed",
)
checkout_duration = meter.create_histogram(
    "checkout.duration",
    unit="ms",
    description="End-to-end checkout latency",
)

def handle_checkout(plan: str) -> None:
    start = time.monotonic()
    # ... do the work ...
    orders_placed.add(1, {"plan": plan})
    checkout_duration.record((time.monotonic() - start) * 1000, {"plan": plan})

if __name__ == "__main__":
    handle_checkout("pro")
    provider.shutdown()  # force a final export before exit

Counters, up-down counters, histograms, and their observable (callback-based) variants are all stable API. Keep attribute cardinality low — every unique attribute combination becomes its own time series.

Logs

Logs use the same provider/processor/exporter shape, plus a bridge into Python’s standard logging module so your existing logging.info(...) calls become OTLP log records — with trace and span IDs attached automatically when emitted inside a span. Note the underscore-prefixed imports (the signal is still stabilizing) and that the handler now lives in the opentelemetry-instrumentation-logging package (the SDK’s own LoggingHandler is deprecated):

pip install opentelemetry-instrumentation-logging
import logging

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

resource = Resource.create({"service.name": "checkout-service"})

logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
    BatchLogRecordProcessor(
        OTLPLogExporter(
            endpoint="http://localhost:5080/api/default/v1/logs",
            headers={"Authorization": "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="},
        )
    )
)
_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()

Because the records flow through the standard logging module, third-party library logs are captured too once the handler is on the root logger. The trace correlation is the payoff: in OpenObserve you can jump from a slow span straight to the log lines it produced.

LoggingHandler is also still importable from opentelemetry.sdk._logs (it emits a DeprecationWarning pointing to opentelemetry-instrumentation-logging), but the instrumentation-logging package is the supported path going forward.

Sending data to OpenObserve

Everything above hard-coded endpoints for clarity. In practice, configure the OTLP exporters entirely through the standard environment variables — the same variables work for the zero-code launcher and for exporters constructed with no arguments in manual code.

Self-hosted OpenObserve (default org default, HTTP OTLP on port 5080):

export OTEL_SERVICE_NAME=checkout-service
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5080/api/default
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="

OpenObserve Cloud (find your organization slug and a ready-made token under Ingestion in the UI):

export OTEL_SERVICE_NAME=checkout-service
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.4.2"
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.openobserve.ai/api/YOUR_ORG_NAME
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20YOUR_BASE64_TOKEN"

Rules that save debugging time:

  • No trailing slash on OTEL_EXPORTER_OTLP_ENDPOINT. The exporter appends /v1/traces, /v1/metrics, /v1/logs itself and handles a trailing slash gracefully, but omitting it is the consistent convention used across OTLP docs and keeps the final URL tidy.
  • Encode the space as %20 in the headers value. The OTLP env-var spec requires URL-encoded values; an unencoded space can be rejected or mis-parsed.
  • Per-signal overrides exist if you need them: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (and the metrics/logs equivalents) take the full URL including the /v1/<signal> path and are used verbatim.
  • OTEL_SERVICE_NAME overrides any service.name in OTEL_RESOURCE_ATTRIBUTES — set it everywhere, since an unset value shows up as unknown_service and makes filtering painful.

gRPC alternative (self-hosted). OpenObserve also accepts OTLP over gRPC on port 5081 (configurable via ZO_GRPC_PORT). gRPC requires an extra organization header because the org is not in the URL:

export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5081
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20YOUR_BASE64_TOKEN,organization=default"

Unless you have a specific reason to use gRPC, stick with http/protobuf — it traverses proxies and load balancers with less ceremony. For a broader tour of exporter options and trade-offs, see the beginner’s guide to OTLP exporters.

Verifying data arrival in OpenObserve

Generate some traffic against your instrumented service, wait a few seconds for the batch processors to flush, then check the OpenObserve UI:

  1. Streams. Open Streams in the left navigation. You should see streams for each signal you export — traces land in a default traces stream, logs and metrics appear as their own stream types. If nothing shows up here, data is not arriving at all; jump to Troubleshooting.
  2. Traces. Open the Traces page, pick the trace stream, and filter by your service name. Click a trace to see the waterfall of spans, including attributes like order.id from the manual example. Spans with ERROR status are flagged, and the exception events recorded via record_exception appear on the span.
  3. Logs. Open Logs, select your log stream, and query. Records emitted inside a span carry trace_id and span_id fields, which is what enables pivoting between a trace and its logs.
  4. Metrics. Open Metrics and look for your instrument names (orders.placed, checkout.duration) to chart them, or add them to a dashboard.

A quick sanity check that bypasses your app entirely — if this curl succeeds (HTTP 200) but your app’s data never shows, the problem is in your app’s exporter config, not in OpenObserve:

curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://localhost:5080/api/default/v1/traces \
  -H "Authorization: Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=" \
  -H "Content-Type: application/json" \
  -d '{"resourceSpans":[]}'

Production tips

Always use batch processors. BatchSpanProcessor and BatchLogRecordProcessor buffer and export off the hot path; the Simple* variants export synchronously per record and belong only in debugging sessions. The zero-code launcher already defaults to batching. Tune with OTEL_BSP_MAX_QUEUE_SIZE, OTEL_BSP_SCHEDULE_DELAY, and OTEL_BSP_MAX_EXPORT_BATCH_SIZE if you push serious volume.

Sample at the head with a parent-based ratio sampler. Tracing every request in a high-traffic service is rarely worth the cost. The standard approach keeps a fixed fraction of traces while respecting upstream decisions, so traces are never half-sampled across services:

export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1   # keep 10% of root traces

Invest in resource attributes. service.name, service.version, and deployment.environment are the three you will filter on daily. Set them via OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES in your deployment manifests so every signal from every replica is consistently tagged.

Flush on shutdown. Batch processors flush on a timer; a short-lived script or a worker killed mid-batch loses whatever is buffered. Call provider.shutdown() (tracer, meter, and logger providers all have it) in your shutdown path — or atexit.register it. The launcher handles this for normal interpreter exit, but not for SIGKILL.

Mind fork-based servers. Pre-fork servers such as Gunicorn create worker processes after the SDK may have initialized, and BatchSpanProcessor threads do not survive fork(). Initialize the SDK in a post-fork hook (for Gunicorn, the post_fork server hook) so each worker gets its own provider and export threads.

Keep instrumentation in sync with dependencies. Instrumentation packages are versioned in lockstep with the SDK. After a dependency bump, re-run opentelemetry-bootstrap -a install and keep opentelemetry-* packages on matching versions — mixing SDK 1.x lines is the most common source of cryptic import errors.

Pin versions if you use logs. As noted above, the logs modules are still underscore-prefixed and have had renames between minor releases. A pinned opentelemetry-sdk plus a changelog read at upgrade time is cheap insurance.

Troubleshooting

Nothing arrives, no errors printed. Batch exporters fail quietly by design. Swap in a console exporter (OTEL_TRACES_EXPORTER=console under the launcher, or ConsoleSpanExporter in code) to confirm spans are being generated, then set the Python logging level for opentelemetry loggers to DEBUG to see export attempts and HTTP status codes.

Connection refused to localhost:4317. The exporter is defaulting to gRPC. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (and make sure the endpoint points at port 5080 for self-hosted OpenObserve).

HTTP 401 from OpenObserve. The Basic token is wrong or the header never made it through. Rebuild it with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token), and check that the space in the env-var form is encoded as %20. Verify independently with the curl command from the verification section.

HTTP 404 on export. Almost always a path problem: a missing organization segment (the path must be /api/<org>), or a per-signal endpoint variable that omits /v1/traces. The final URL must look like http://localhost:5080/api/default/v1/traces.

Spans generated but lost on exit. Short-lived processes exit before the batch interval fires. Call tracer_provider.shutdown() / meter_provider.shutdown() / logger_provider.shutdown() before the process ends, or use force_flush() at checkpoints in long batch jobs.

Auto-instrumentation produces no spans for your framework. Either the matching instrumentation package is missing (pip list | grep opentelemetry-instrumentation then re-run opentelemetry-bootstrap -a install), or the app was imported before instrumentation applied — with pre-fork servers, make sure opentelemetry-instrument wraps the server command itself or use the post-fork initialization pattern above.

Logs export but without trace IDs. The log record was emitted outside an active span, or through a handler attached before the LoggerProvider was set. Attach the LoggingHandler after SDK initialization, and confirm the emitting code actually runs inside start_as_current_span.

With traces, metrics, and correlated logs flowing into one backend, you can move from “an alert fired” to “this span, in this request, produced this log line” without leaving OpenObserve. Start with the zero-code launcher, add manual spans where your business logic needs them, and let the standard environment variables carry the configuration from laptop to production.

Frequently asked questions

Are OpenTelemetry traces, metrics, and logs stable in Python?

Traces and metrics are stable in the OpenTelemetry Python SDK and safe for production use. The logs signal is still marked as in development: it works end to end and is widely used, but log-related APIs live in underscore-prefixed modules and have seen breaking changes between releases, so pin your SDK version if you rely on logs.

Do I need to change my code to use OpenTelemetry with Python?

No. The opentelemetry-instrument command wraps your existing application and automatically instruments supported libraries such as Flask, FastAPI, Django, requests, and SQLAlchemy without any code changes. You install opentelemetry-distro, run opentelemetry-bootstrap to detect your dependencies, and launch your app with opentelemetry-instrument. Manual SDK setup is only needed for custom spans, metrics, or fine-grained control.

How do I send OpenTelemetry data from Python to OpenObserve?

Point the OTLP exporter at your OpenObserve instance using standard environment variables. Set OTEL_EXPORTER_OTLP_ENDPOINT to your OpenObserve API base such as http://localhost:5080/api/default for self-hosted or https://api.openobserve.ai/api/YOUR_ORG for OpenObserve Cloud, set OTEL_EXPORTER_OTLP_PROTOCOL to http/protobuf, and pass a Basic auth token in OTEL_EXPORTER_OTLP_HEADERS. The exporter appends the per-signal paths v1/traces, v1/metrics, and v1/logs automatically.

Should I use gRPC or HTTP OTLP with OpenObserve?

HTTP with protobuf encoding is the simplest and most portable choice, and it is what OpenObserve exposes on port 5080 under the /api/organization path. OpenObserve also accepts OTLP over gRPC on port 5081 for self-hosted deployments, which requires an additional organization header alongside the Basic authorization metadata. If you are unsure, start with http/protobuf.

Why is my Python service exporting no data even though there are no errors?

The most common causes are the process exiting before the batch processors flush, a wrong OTLP protocol so the exporter targets gRPC port 4317 instead of your HTTP endpoint, or a malformed OTEL_EXPORTER_OTLP_HEADERS value where the space in the Basic token was not encoded as %20. Enable console exporters or set OTEL_LOG_LEVEL to debug to see what the SDK is doing, and call shutdown on your providers before exit.

Does OpenTelemetry auto-instrumentation work with any Python framework?

Yes. The instrumentation ecosystem covers most popular libraries, and opentelemetry-bootstrap automatically installs the packages that match what is already in your environment. Anything that is not covered can be instrumented manually with the tracing API, and both approaches compose in the same process.

Keep reading

Ship your OpenTelemetry data somewhere better

OpenObserve is a native OTLP backend for logs, metrics, and traces — open source, S3-backed, and a fraction of the cost of legacy observability tools.

Book a DemoBook a Demo