Jaeger is an open source distributed tracing platform: it records the path of each request through your services as a trace made of spans, so you can see where time is spent and where errors start. Jaeger v2 is built on the OpenTelemetry Collector and receives traces natively over OTLP, the OpenTelemetry protocol. In this tutorial you will run Jaeger v2 with Docker on Ubuntu 24.04, instrument a small Python Flask application with OpenTelemetry, and analyze its traces in the Jaeger UI.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS with at least 2 GB of RAM, for example a CubePath VPS.
  • A non-root user with sudo privileges.
  • Docker Engine installed from Docker's official repository, with your user able to run docker (or prefix the Docker commands with sudo).
  • Python 3 (included in Ubuntu 24.04).

Step 1 - Running Jaeger with Docker

The jaegertracing/jaeger image contains the whole Jaeger v2 binary. Without a configuration file it runs in all-in-one mode: OTLP receivers, in-memory storage and the web UI in a single container. Start it with a restart policy so it comes back after a reboot:

docker run -d --name jaeger \
  --restart unless-stopped \
  -p 127.0.0.1:16686:16686 \
  -p 127.0.0.1:4317:4317 \
  -p 127.0.0.1:4318:4318 \
  jaegertracing/jaeger:latest

The published ports are:

PortPurpose
16686Jaeger UI and query API
4317OTLP over gRPC
4318OTLP over HTTP

All three are bound to 127.0.0.1. This matters because Docker writes its own iptables rules for published ports, which bypass UFW: a port published as -p 16686:16686 would be reachable from the internet even if UFW denies it.

Check that the container is running and has started its components:

docker ps --filter name=jaeger
docker logs jaeger 2>&1 | grep -i "everything is ready"
CONTAINER ID   IMAGE                         COMMAND                  CREATED          STATUS          PORTS                                                                                          NAMES
8c1f0e7b2a4d   jaegertracing/jaeger:latest   "/cmd/jaeger/jaeger-…"   20 seconds ago   Up 19 seconds   127.0.0.1:4317-4318->4317-4318/tcp, 127.0.0.1:16686->16686/tcp, ...                          jaeger
{"level":"info","ts":1790247000.1,"msg":"Everything is ready. Begin running and processing data."}

Query the API to confirm the UI backend answers:

curl -s http://localhost:16686/api/services
{"data":null,"total":0,"limit":0,"offset":0,"errors":null}

No services exist yet because nothing has sent traces.

Step 2 - Opening the Jaeger UI through an SSH tunnel

Since the UI only listens on localhost, reach it from your computer with an SSH tunnel. Run this on your local machine:

ssh -L 16686:localhost:16686 your_user@your_server_ip

Keep the session open and browse to http://localhost:16686. You will see the Jaeger search page with an empty Service list.

Step 3 - Creating a sample Flask application

To have something to trace, create a small web application with two endpoints. Install the Python virtual environment module and create a project directory:

sudo apt update
sudo apt install python3-venv
mkdir -p ~/demo-shop && cd ~/demo-shop
python3 -m venv venv
source venv/bin/activate

Install Flask together with the OpenTelemetry distribution and the OTLP exporter:

pip install flask opentelemetry-distro opentelemetry-exporter-otlp

The opentelemetry-bootstrap tool inspects the installed packages and adds the matching auto-instrumentation libraries (for Flask, it installs opentelemetry-instrumentation-flask):

opentelemetry-bootstrap -a install

Now create the application:

nano app.py
import random
import time

from flask import Flask
from opentelemetry import trace

app = Flask(__name__)
tracer = trace.get_tracer("demo-shop")


@app.route("/")
def index():
    return "demo-shop is running\n"


@app.route("/checkout")
def checkout():
    with tracer.start_as_current_span("load-cart") as span:
        items = random.randint(1, 5)
        span.set_attribute("cart.items", items)
        time.sleep(0.02 * items)

    with tracer.start_as_current_span("charge-card") as span:
        span.set_attribute("payment.provider", "demo")
        time.sleep(random.uniform(0.05, 0.4))
        if random.random() < 0.1:
            span.set_status(trace.StatusCode.ERROR, "card declined")
            return "payment failed\n", 402

    return "order confirmed\n"

Flask auto-instrumentation creates a span for every incoming HTTP request. Inside /checkout, the two start_as_current_span blocks add child spans for the cart and payment steps, with attributes and, in about 10% of requests, an error status.

Step 4 - Sending traces to Jaeger

Run the application through opentelemetry-instrument, which configures the SDK from its options and environment variables and exports spans over OTLP gRPC to localhost:4317:

export OTEL_SERVICE_NAME=demo-shop
export OTEL_TRACES_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=none
export OTEL_LOGS_EXPORTER=none
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
opentelemetry-instrument flask --app app run --port 5000

OTEL_SERVICE_NAME is the name the service will have in Jaeger. Metrics and logs exporters are disabled because this Jaeger instance only accepts traces. Do not start Flask in debug mode: its code reloader runs the app in a child process and breaks auto-instrumentation.

Open a second SSH session to the server and generate some traffic:

for i in $(seq 1 30); do curl -s http://localhost:5000/checkout; done
order confirmed
order confirmed
payment failed
order confirmed
...

Spans are exported in batches every few seconds. Confirm Jaeger received them by listing the services again:

curl -s http://localhost:16686/api/services
{"data":["demo-shop"],"total":1,"limit":0,"offset":0,"errors":null}

Step 5 - Analyzing traces in the Jaeger UI

Reload the Jaeger UI in your browser, select demo-shop in the Service list and click Find Traces. Each result is one GET /checkout request, with its total duration and number of spans. Traces that contain an error are marked in red.

Click a trace to open the timeline view. It shows:

  • The root span GET /checkout, created by the Flask instrumentation, with HTTP attributes such as http.route and the status code.
  • The child spans load-cart and charge-card, drawn as bars on a shared time axis, so you can see immediately which step used most of the request time.
  • For each span, its attributes (cart.items, payment.provider) and, for failed payments, the error status and message.

The search form also lets you narrow down traces. A few useful filters:

  • Tags: error=true returns only the failed requests.
  • Min Duration: 300ms shows only slow checkouts, which in this demo are dominated by charge-card.
  • Operation: pick charge-card to find traces by a specific span name.

Select two traces with the checkboxes and click Compare Traces to see a side-by-side diff of their span structure and timings. As you instrument more services and propagate the trace context between them (the OpenTelemetry HTTP client instrumentations do this automatically), the System Architecture tab builds a dependency graph of which services call which.

Stop the Flask application with Ctrl+C when you are done.

Step 6 - Planning for persistent storage

The all-in-one mode keeps traces in memory: they are lost when the container restarts, and old traces are discarded once the in-memory limit is reached. This is fine for development and for learning the tool. For production, Jaeger v2 is started with a YAML configuration file (--config) that selects a persistent backend such as Badger (local disk, single node), Elasticsearch, OpenSearch or Cassandra. The Jaeger documentation includes example configurations for each backend; mount yours into the container and keep the same OTLP ports so your applications do not need to change.

If applications on other servers must send traces here, do not publish 4317 or 4318 on all interfaces. Put the servers on a private network, or place an OpenTelemetry Collector on each host that forwards to Jaeger over a private or TLS-protected connection.

Troubleshooting

  • No service appears in Jaeger: make sure the application was started through opentelemetry-instrument and that OTEL_TRACES_EXPORTER=otlp is set in the same shell. Export errors such as Failed to export traces are printed in the application's output.
  • StatusCode.UNAVAILABLE errors from the exporter: Jaeger is not listening on 4317. Check docker ps and docker logs jaeger.
  • Only the HTTP span appears, without Flask spans: opentelemetry-bootstrap -a install was run before Flask was installed, or Flask runs in debug mode. Rerun the bootstrap inside the virtual environment and start Flask without --debug.
  • The UI does not load in the browser: the SSH tunnel must stay open, and nothing else on your local machine may be using port 16686.

Conclusion

You now have Jaeger v2 running on Ubuntu 24.04, receiving OpenTelemetry traces over OTLP, and you used its UI to find slow and failed requests and inspect individual spans. Next steps are to instrument your real services with the OpenTelemetry SDK for their language, add an OpenTelemetry Collector in front of Jaeger to control sampling and routing, and configure a persistent storage backend before relying on Jaeger in production.