Skip to content

intermediate ~75 min updated 2026-06-01

OpenTelemetry Tracing

Instrument a Python Flask service with OpenTelemetry auto-instrumentation, ship spans through the OpenTelemetry Collector via OTLP, and inspect distributed traces in Jaeger.

Objective

Auto-instrument a Flask app with OpenTelemetry, route spans through an OTel Collector pipeline, and view the resulting traces in Jaeger. You will learn the receiver → processor → exporter pipeline model that makes the Collector the standard telemetry router.

Prerequisites

  • Docker and Docker Compose installed
  • Python 3.10 or newer
  • pip and the venv module available
  • Basic familiarity with HTTP APIs

Architecture

The Flask app is instrumented with zero code changes using opentelemetry-instrument. It exports spans over OTLP/gRPC to the OpenTelemetry Collector, which batches them and forwards to Jaeger’s native OTLP endpoint. Jaeger stores spans in memory and serves the trace UI.

 curl requests
      |
      v
+-------------+  OTLP gRPC   +------------------+  OTLP gRPC  +-----------+
| Flask app   | -----------> | OTel Collector   | ----------> | Jaeger    |
| (port 5000) |  :4317       | receiver: otlp   |  :4317      | all-in-one|
| auto-instr. |              | processor: batch |             | UI :16686 |
+-------------+              | exporter: otlp   |             +-----------+

Steps

1. Start Jaeger and the Collector with Docker Compose

# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/jaeger:2.5.0
    ports:
      - "16686:16686"   # UI

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.123.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otel-config.yaml:/etc/otelcol/config.yaml
    ports:
      - "4317:4317"     # OTLP gRPC in
    depends_on:
      - jaeger

2. Write the Collector configuration

# otel-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/jaeger, debug]
docker compose up -d
docker compose ps

3. Create the Flask application

mkdir app && cd app
python3 -m venv .venv && source .venv/bin/activate
pip install flask requests \
  opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
# main.py
import time
import requests
from flask import Flask, jsonify

app = Flask(__name__)

@app.get("/checkout")
def checkout():
    # downstream HTTP call -> creates a child span automatically
    r = requests.get("https://httpbin.org/delay/1", timeout=10)
    time.sleep(0.2)  # simulate work
    return jsonify(order="A-1001", upstream=r.status_code)

if __name__ == "__main__":
    app.run(port=5000)

4. Run with auto-instrumentation

export OTEL_SERVICE_NAME=checkout-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=none
export OTEL_LOGS_EXPORTER=none

opentelemetry-instrument flask --app main run --port 5000

5. Generate traffic

In a second terminal:

for i in $(seq 1 10); do curl -s http://localhost:5000/checkout; echo; done

6. Inspect traces in Jaeger

Open http://localhost:16686, choose service checkout-service, and click Find Traces. Open one trace: you should see a parent GET /checkout span with a child GET span for the httpbin call. Confirm the Collector saw the spans too:

docker compose logs otel-collector | grep -i "TracesExporter" | tail -3

Expected output

$ curl -s http://localhost:5000/checkout
{"order":"A-1001","upstream":200}

$ docker compose logs otel-collector | grep -i traces | tail -2
otel-collector-1 | info  TracesExporter  {"kind": "exporter", "name": "debug",
  "resource spans": 1, "spans": 2}

Jaeger UI trace view:
checkout-service: GET /checkout        1.31s
  └─ checkout-service: GET httpbin.org 1.09s

Troubleshooting

  • No traces in Jaeger but the app works: the exporter endpoint is wrong. Verify OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 and that port 4317 is published by the collector container (docker compose ps).
  • StatusCode.UNAVAILABLE connection errors from the SDK: the Collector is not up or crashed on a bad config. Check docker compose logs otel-collector for YAML parse errors — indentation in otel-config.yaml matters.
  • Spans reach the Collector (debug exporter logs them) but not Jaeger: the Jaeger exporter endpoint must be the in-network hostname jaeger:4317, not localhost, and needs tls.insecure: true for plaintext.
  • Only one span per trace, no child span: opentelemetry-bootstrap -a install was skipped, so the requests library is not instrumented. Run it inside the virtualenv and restart.
  • opentelemetry-instrument: command not found: the virtualenv is not activated. Run source .venv/bin/activate first.

Cleanup

# Stop the Flask app with Ctrl+C, then:
deactivate
cd ..
docker compose down -v
rm -rf app docker-compose.yml otel-config.yaml