DEV Community

divyansh
divyansh

Posted on

Instrumenting a Multi-Stage ML Pipeline with OpenTelemetry and SigNoz

I've been working on AetherPhoto, a backend that organizes photos using natural language search and facial recognition. The architecture splits work across two main components: a FastAPI server that handles HTTP requests (like search and OAuth), and a Celery worker pool that processes the heavy lifting.

When you upload a batch of photos or sync from Google Photos, the API queues jobs in Redis. The Celery workers pick these up and run each image through a gauntlet: EXIF extraction, CLIP embedding for semantic search, zero-shot classification, perceptual hashing, and MTCNN/FaceNet for face detection. Finally, the data lands in a Neon PostgreSQL database and a Qdrant vector store.

graph LR
    User[Browser] -->|HTTP| API[FastAPI API<br/>aetherphoto-api]
    API -->|OTLP/gRPC| Collector[SigNoz OTel Collector<br/>:4317]
    API -->|Dispatch Task| Redis[(Upstash Redis)]
    Redis --> Worker[Celery Worker<br/>aetherphoto-worker]
    Worker -->|OTLP/gRPC| Collector
    API -->|SQL| PG[(PostgreSQL)]
    Worker -->|SQL| PG
    Worker -->|Vector Upsert/Search| Qdrant[(Qdrant)]
    Worker -->|Download Photos| Google[Google Photos API]
    Worker -->|CLIP + FaceNet| ML[ML Models]
    Collector --> SigNoz[SigNoz UI<br/>:3301]
Enter fullscreen mode Exit fullscreen mode

When an ingestion job took 40 seconds instead of four, my standard logging setup wasn't helping. I could see logs across the API and the worker, but stitching them together by timestamps to figure out if Qdrant was lagging or if the CLIP model inference was slow was miserable. I needed distributed tracing.

I decided to instrument the entire pipeline using OpenTelemetry and export the traces to a local instance of SigNoz. I chose SigNoz because it accepts OTLP directly and gave me a UI to query traces without shipping data to a managed service during local development.

The Telemetry Foundation

I started by creating a centralized telemetry.py module. Both the FastAPI app and the Celery workers need to initialize tracing, and I wanted them pointing to the same OTLP endpoint with consistent resource attributes.

from opentelemetry import trace
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.sdk.resources import Resource, SERVICE_NAME

_provider: TracerProvider | None = None

def init_telemetry(service_name: str) -> TracerProvider:
    global _provider
    if _provider is not None:
        return _provider

    resource = Resource.create(
        {
            SERVICE_NAME: service_name,
            "deployment.environment": os.environ.get("ENV", "development"),
        }
    )

    _provider = TracerProvider(resource=resource)

    # Defaults to localhost:4317 for the SigNoz collector
    otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
    exporter = OTLPSpanExporter(
        endpoint=otlp_endpoint,
        insecure=not otlp_endpoint.startswith("https"),
    )

    span_processor = BatchSpanProcessor(exporter)
    _provider.add_span_processor(span_processor)
    trace.set_tracer_provider(_provider)

    return _provider
Enter fullscreen mode Exit fullscreen mode

I used BatchSpanProcessor so the tracer exports spans in the background without blocking the API or the ML worker threads. By calling init_telemetry("aetherphoto-api") in my main.py and init_telemetry("aetherphoto-worker") in celery_app.py, SigNoz immediately recognized both as distinct services.

The SigNoz services dashboard interface displaying the newly instrumented services. You should see two active services listed: aetherphoto-api (representing the FastAPI app server) and aetherphoto-worker (representing the Celery background worker processes), showing key performance metrics like RPS, Error Rate, and P99 latency side-by-side.<br>

Quick Wins: Auto-Instrumentation

OpenTelemetry's Python ecosystem includes auto-instrumentors that patch standard libraries at runtime. I added the instrumentors for FastAPI, SQLAlchemy, HTTPX, and Celery.

In main.py, the setup looks like this:

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from api.db.session import engine

# Trace outbound HTTP calls
HTTPXClientInstrumentor().instrument()

# Trace all DB queries
SQLAlchemyInstrumentor().instrument(engine=engine)

app = FastAPI(...)

# Trace incoming HTTP requests
FastAPIInstrumentor.instrument_app(app)
Enter fullscreen mode Exit fullscreen mode

Hitting the /health endpoint immediately produced a trace. The FastAPIInstrumentor captured the route and HTTP method as the root span, and the SQLAlchemyInstrumentor captured the SELECT 1 query as a child span, including the exact SQL statement.

A detailed trace view of the GET /health endpoint inside the SigNoz UI. This visualizes a root span representing the incoming FastAPI request, containing a nested child span labeled db.query generated automatically by the SQLAlchemy instrumentor, showing the execution of the relational database sanity check query.

The Celery instrumentor is arguably the most valuable. By calling CeleryInstrumentor().instrument() in the worker setup, OpenTelemetry injects the current trace context into the Redis message headers when the API calls .delay(). When the worker pulls the task, it extracts those headers and continues the trace.

 distributed trace view showcasing trace propagation from the API web process to the background task worker. The parent span originates from the POST /ingest/local API endpoint, with a span signaling the Redis payload publication, which then links directly to the start of the ingest_local_directory task span in the worker service.

Filling the Gaps: Manual Spans

Auto-instrumentation handles HTTP and SQL, but it doesn't know what my ML pipeline is doing. Inside embed_photo_task.py, a single Celery task runs image downloads, EXIF extraction, CLIP embedding, and face detection sequentially. Without manual spans, this entire process would show up in SigNoz as one massive, opaque block of execution time.

I needed to see exactly how long the CLIP inference took compared to the Qdrant vector upsert. I imported get_tracer(__name__) and wrapped each distinct operation using tracer.start_as_current_span().

Here is how I instrumented the Qdrant and zero-shot classification steps:

# 3. Store Embedding in Qdrant Vector database
with tracer.start_as_current_span(
    "qdrant.upsert_embedding",
    attributes={
        "db.system": "qdrant",
        "db.operation": "upsert",
        "photo.id": photo_id,
    },
) as span:
    vector_store.upsert_image_embedding(photo.id, embedding, payload)

# 4. Zero-Shot Classification
with tracer.start_as_current_span(
    "clip.zero_shot_classify",
    attributes={
        "photo.id": photo_id,
        "clip.model": "openai/clip-vit-base-patch32",
    },
) as span:
    category = category_service.classify_image(embedding)
    photo.category = category
    span.set_attribute("photo.category", category)
Enter fullscreen mode Exit fullscreen mode

Adding custom attributes like photo.category and photo.id proved useful. I can now filter in SigNoz to find all traces where the CLIP model classified an image as "documents" to see if those take longer to process than "landscapes".

I applied this same pattern across the codebase: wrapping the DBSCAN face clustering, the perceptual hash duplicate detection, and the Google Photos Picker polling loop.

A trace waterfall view of the embed_photo_task execution. This shows the sequenced breakdown of our manual spans: image.download, exif.extract_metadata, clip.image_embedding (inference), qdrant.upsert_embedding (vector store upload), clip.zero_shot_classify, hash.calculate_phash, face.detect_and_embed, and the final database updates.

Catching Hidden Failures

My Google Photos ingestion flow relies on fetching temporary URLs from the Picker API. These URLs are ephemeral and expire after about an hour. If a user selects 5,000 photos, the background ingestion might take longer than an hour, meaning the last few hundred image downloads will fail.

Before tracing, I'd just see a generic task failure in the Celery logs. With my manual spans recording exceptions, the failure mode is obvious. I catch the exception inside the image.download span, call span.record_exception(e), and set the span status to error:

with tracer.start_as_current_span("image.download") as span:
    try:
        response = httpx.get(photo.file_path_or_url)
        response.raise_for_status()
        # ... write to file ...
    except Exception as e:
        span.record_exception(e)
        span.set_status(trace.StatusCode.ERROR, str(e))
        raise
Enter fullscreen mode Exit fullscreen mode

When the token expires, the HTTPX client throws an HTTPStatusError for a 403 Forbidden. The root embed_photo_task span is marked as an error, and the exact child span (image.download) holds the 403 response text. I can filter my traces by photo.source="google_photos" and status=ERROR to immediately see how many photos failed due to token expiration.

A red-flagged error trace in SigNoz indicating a failure. The trace expands the image.download span to show a logged exception object. The metadata panel on the right displays the exception.type as httpx.HTTPStatusError, the message detailing an HTTP 403 Forbidden error, and the stack trace highlighting the token expiry.

Mapping the Topology

Because I passed engine to the SQLAlchemyInstrumentor and wrapped Qdrant calls with attributes like db.system: qdrant, SigNoz automatically inferred the topology of my application.

The Service Map view visualizes this beautifully. I can see the API server dispatching messages to Redis, the worker consuming them, and then the worker fanning out connections to PostgreSQL, Qdrant, and external Google domains. When I deploy this to production, this map will give me an immediate visual indicator of where latency is pooling—whether it's the database queries slowing down or the vector store struggling under load.

The auto-generated SigNoz Service Map topology visualization. It shows nodes for aetherphoto-api and aetherphoto-worker with arrows connecting them through the Redis instance, and outward calls from the worker node pointing to PostgreSQL, Qdrant, and the external Google Photos API.

Top comments (0)