# 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.

Source: https://openobserve.ai/opentelemetry/python/
Published: 2026-07-08
Language: Python

---

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](https://openobserve.ai/) over OTLP. If you are new to the project itself, start with [What is OpenTelemetry?](/blog/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).

| Signal  | Python SDK status | Notes |
|---------|-------------------|-------|
| Traces  | Stable | Production-ready; API is frozen |
| Metrics | Stable | Production-ready; API is frozen |
| Logs    | In development | Functional 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](https://cloud.openobserve.ai) (free tier available) or self-hosted - a single binary or container listening on port 5080:

```shell
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):

```shell
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:

```shell
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:

```shell
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](/blog/monitoring-fastapi-application-using-opentelemetry-and-openobserve/).

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

```shell
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:

```shell
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:

```python
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](/blog/handling-errors-with-opentelemetry-python/) 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):

```python
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):

```shell
pip install opentelemetry-instrumentation-logging
```

```python
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):

```shell
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):

```shell
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:

```shell
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](/blog/otel-exporters-introduction/).

## 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:

```shell
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:

```shell
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.
