./read-blog.sh --post="open-telemetry-the-easy-way"

Open Telemetry The Easy Way

This article simplifies the process of setting up jaegar with open telemetry in your code base. Instead of unending function wrappers and confusion about aggregating telemetry data, this article will explain a simple way to add function calls your trace data.

[AUTHOR] Joe Coppeta
[DATE] Jan 14, 2026
[TIME] 2 min read

Why Telemetry?

Telemetry is a means for collecting metrics and traces that explain and measure latency. This is especially useful for pinpointing performance issues and identifying the full request dependency tree among other things.

There are two main types within telemetry data that you will want to know. These are:

  1. Traces
  2. Spans

Traces represent the lifecycle of a single request as it flows through a service.

Spans represent a singular timed operated within a given trace such as a function call.

Now that you are familiar with the absolute basics, let's spin up a simple FastAPI server and Jaegar frontend to demonstrate the capabilities.

Code Walkthrough

Environment Setup

Start by creating the following files

.env
env
OTEL_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
SERVICE_NAME=my-fastapi
APP_ENV=development
requirements.text
env
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
python-dotenv>=1.0.1
opentelemetry-api>=1.23.0
opentelemetry-sdk>=1.23.0
opentelemetry-exporter-otlp>=1.23.0
opentelemetry-instrumentation>=0.44b0
opentelemetry-instrumentation-fastapi>=0.44b0
opentelemetry-instrumentation-asgi>=0.44b0
opentelemetry-instrumentation-requests>=0.44b0

Now, create a virtual environment and install the dependencies

bash
python -m venv venv

source venv/bin/active

pip install -r requirements.txt

FastAPI Server

Copy the following FastAPI server code to a file called main.py

main.py
python
import os
from dotenv import load_dotenv
from fastapi import FastAPI

from telemetry import setup_telemetry, addToTrace

load_dotenv()

app = FastAPI(title="My API")

@app.on_event("startup")
async def startup():
    os.environ.setdefault("OTEL_ENABLED", "true")
    os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
    os.environ.setdefault("SERVICE_NAME", "my-fastapi")
    os.environ.setdefault("APP_ENV", "development")

    setup_telemetry(
        app,
        service_name=os.environ["SERVICE_NAME"]
    )

    print("[Successfully setup jaegar connection]")

@app.get("/")
@addToTrace
def health():
    return {"status": "ok"}

@addToTrace
async def expensive_logic(x: int) -> int:
    return x * 2

@app.get("/work")
@addToTrace 
async def do_work(x: int = 2):
    y = await expensive_logic(x)
    return {"x": x, "y": y}

Open Telemetry Config

Copy the following code to a file called telemetry.py

telemetry.py
python
import os
import functools
import inspect

from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
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.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator


def setup_telemetry(app, service_name: str):
    """
    Initializes OpenTelemetry for FastAPI + requests.
    Controlled by env var OTEL_ENABLED=true.
    Exports spans via OTLP gRPC to OTEL_EXPORTER_OTLP_ENDPOINT.
    """
    if os.getenv("OTEL_ENABLED", "false").lower() != "true":
        return

    set_global_textmap(TraceContextTextMapPropagator())

    resource = Resource.create({
        SERVICE_NAME: service_name,
        "deployment.environment": os.getenv("APP_ENV", "development"),
    })

    provider = TracerProvider(resource=resource)

    otlp_exporter = OTLPSpanExporter(
        endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
    )

    provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
    trace.set_tracer_provider(provider)

    RequestsInstrumentor().instrument()
    FastAPIInstrumentor.instrument_app(
        app,
        tracer_provider=provider,
        client_response_hook=response_hook,
    )

def response_hook(span: trace.Span, scope: dict, message: dict):
    """Captures HTTP response status codes."""
    if message.get("type") == "http.response.start":
        status = message.get("status")
        if status is not None:
            span.set_attribute("http.response.status_code", status)
            if status >= 500:
                span.set_status(Status(StatusCode.ERROR))


def addToTrace(func):
    """Decorator for internal business-logic child spans (async + sync)."""
    tracer = trace.get_tracer(__name__)

    @functools.wraps(func)
    async def async_wrapper(*args, **kwargs):
        with tracer.start_as_current_span(func.__name__) as span:
            try:
                result = await func(*args, **kwargs)
                span.set_status(Status(StatusCode.OK))
                return result
            except Exception as e:
                span.record_exception(e)
                span.set_status(Status(StatusCode.ERROR, str(e)))
                raise

    @functools.wraps(func)
    def sync_wrapper(*args, **kwargs):
        with tracer.start_as_current_span(func.__name__) as span:
            try:
                result = func(*args, **kwargs)
                span.set_status(Status(StatusCode.OK))
                return result
            except Exception as e:
                span.record_exception(e)
                span.set_status(Status(StatusCode.ERROR, str(e)))
                raise

    return async_wrapper if inspect.iscoroutinefunction(func) else sync_wrapper

Jaegar Frontend

First, make sure that docker is running. After that, we can run the following command to spin up the Jaegar frontend server.

bash
docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:1.56

Putting it all Together

Now that your dependencies are installed, your files are present, and jaegar is running, all that is left to do is start your server and you should see the following logs

bash
$ uvicorn main:app --host 0.0.0.0 --port 8000 --reload
INFO:     Will watch for changes in these directories: ['/']
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [4021] using WatchFiles
INFO:     Started server process [4024]
INFO:     Waiting for application startup.
[Successfully setup jaegar connection]
INFO:     Application startup complete.

Next, make a simple call to each endpoint

javascript
curl http://localhost:8000/

curl http://localhost:8000/work

Now we can look at the frontend by going to http://localhost:16686

PACKET_TYPE: IMAGE_DATA
Jaegar visual

Jaegar frontend view

Additionally, we can click into the /work trace to see the full latency profile

PACKET_TYPE: IMAGE_DATA

That's it! All you need to do now to add additionally route or functions is simply include the @addToTrace decorator and the rest is done automatically.

Conclusion

<LFTF />

Lead From The Front - we provide you with the latest news, demos, and everything you need to advance your career and support those around you. Learn about coding, networking, DevOps, modern web development, and more.

Quick Links

Connect

© 2026 LFTF (Lead From The Front). All rights reserved.