Distributed Tracing met OpenTelemetry: Zichtbaarheid in Microservices

Hoe OpenTelemetry en Jaeger distributed tracing praktisch maken voor microservice architecturen — van instrumentatie tot het vinden van bottlenecks.

Jean-Pierre Broeders

Freelance DevOps Engineer

14 maart 20267 min. leestijd

Distributed Tracing met OpenTelemetry

Metrics vertellen dat iets langzaam is. Logs vertellen wat er misging. Maar geen van beide beantwoordt de vraag: waar in de keten van twaalf microservices zit het probleem precies? Dat is waar distributed tracing het verschil maakt.

Het Probleem met Microservices Debugging

Een gebruiker klikt op "bestelling plaatsen". Achter de schermen raakt dat request de API gateway, de order service, de payment service, een fraud check, inventory management, en een notificatie service. Response time: 4.2 seconden. Waar zit de vertraging?

Zonder tracing begint het giswerk. Elke teamlead wijst naar een andere service. "Bij ons draait alles normaal." Herkenbaar? Met distributed tracing is één klik op een trace genoeg om te zien dat de fraud check 3.1 seconden op een externe API wacht.

OpenTelemetry: De Standaard

OpenTelemetry (OTel) heeft de fragmentatie in de tracing-wereld opgelost. Waar eerder Jaeger, Zipkin en diverse vendor-specifieke SDKs naast elkaar bestonden, biedt OTel nu één gestandaardiseerde manier om telemetry data te verzamelen. Het project wordt gedragen door de CNCF en heeft breed draagvlak bij cloud providers en tooling vendors.

De architectuur bestaat uit drie componenten:

  • SDK — instrumenteert de applicatiecode en genereert traces
  • Collector — ontvangt, verwerkt en exporteert telemetry data
  • Backend — slaat traces op en maakt ze doorzoekbaar (Jaeger, Tempo, etc.)

Instrumentatie in .NET

Voor een .NET applicatie is de setup verrassend weinig werk. De OpenTelemetry SDK integreert netjes met het dependency injection systeem.

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation()
            .AddSource("OrderService")
            .AddOtlpExporter(opts =>
            {
                opts.Endpoint = new Uri("http://otel-collector:4317");
            });
    });

Dit voegt automatisch spans toe voor inkomende HTTP requests, uitgaande HTTP calls en database queries. Zonder één regel custom code is al zichtbaar hoe een request door de applicatie stroomt.

Voor custom spans — bijvoorbeeld rond een complexe business operatie — werkt het zo:

private static readonly ActivitySource Source = new("OrderService");

public async Task<Order> ProcessOrder(OrderRequest request)
{
    using var activity = Source.StartActivity("ProcessOrder");
    activity?.SetTag("order.customer_id", request.CustomerId);
    activity?.SetTag("order.item_count", request.Items.Count);

    var validated = await ValidateInventory(request);
    var payment = await ProcessPayment(validated);

    activity?.SetTag("order.total", payment.Amount);
    return await FinalizeOrder(payment);
}

Elke StartActivity call creëert een span die automatisch gekoppeld wordt aan de parent trace. De tags maken het mogelijk om later te filteren: alle orders van een specifieke klant, of alle orders met meer dan vijf items.

De OTel Collector

De Collector draait als tussenlaag tussen applicaties en het backend. Dat klinkt als onnodige complexiteit, maar het ontkoppelt de applicatie van de storage keuze. Wissel van Jaeger naar Grafana Tempo zonder één regel applicatiecode aan te raken.

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

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow-requests
        type: latency
        latency: { threshold_ms: 2000 }

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch, tail_sampling]
      exporters: [otlp/jaeger]

De tail_sampling processor is hier cruciaal. In productie genereert een drukke applicatie duizenden traces per minuut. Alles opslaan is duur en onnodig. Tail sampling bewaart alleen traces die er toe doen: errors en trage requests. De rest wordt weggegooid.

Docker Compose Setup

Een werkende tracing stack past in een compact Compose bestand:

services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"

  jaeger:
    image: jaegertracing/all-in-one:1.54
    environment:
      COLLECTOR_OTLP_ENABLED: "true"
    ports:
      - "16686:16686"  # UI
      - "4317"         # OTLP gRPC

  order-service:
    build: ./src/OrderService
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
      OTEL_SERVICE_NAME: order-service

Na docker compose up is de Jaeger UI beschikbaar op poort 16686. Zoek op service naam, filter op duur of status, en klik een trace open om de volledige request flow te zien.

Context Propagation: Het Geheim

Tracing werkt over service-grenzen heen doordat context automatisch wordt meegegeven in HTTP headers. Het W3C Trace Context formaat (traceparent header) is de standaard. De meeste HTTP clients en frameworks propageren dit automatisch wanneer de OTel SDK actief is.

Waar het misgaat: message queues. Bij asynchrone communicatie via RabbitMQ of Kafka moet de trace context expliciet worden meegegeven in message headers.

// Producer
var propagator = Propagators.DefaultTextMapPropagator;
propagator.Inject(
    new PropagationContext(Activity.Current!.Context, Baggage.Current),
    message.Headers,
    (headers, key, value) => headers[key] = value
);

// Consumer
var parentContext = propagator.Extract(
    default,
    message.Headers,
    (headers, key) => headers.TryGetValue(key, out var val) ? [val] : []
);
using var activity = Source.StartActivity("ProcessMessage",
    ActivityKind.Consumer, parentContext.ActivityContext);

Zonder dit stuk code stopt de trace bij de queue producer en begint een nieuwe, ongerelateerde trace bij de consumer. De end-to-end zichtbaarheid is dan weg.

Wat Tracing Oplevert

ScenarioZonder TracingMet Tracing
Trage endpoint vindenUren logfiles doorzoekenFilter op latency > 2s, klik trace open
Cascade failure debuggenTeams wijzen naar elkaarTrace toont exact welke downstream call faalt
Performance regressiePas zichtbaar na klachtenSpan duration dashboards tonen trends
Dependency mappingHandmatig bijgehouden wikiAutomatisch gegenereerd uit traces

Valkuilen

Een paar dingen die in de praktijk fout gaan. Ten eerste: te veel custom spans. Elke span heeft overhead. Een span rond elke method call maakt traces onleesbaar en kost performance. Instrumenteer op het niveau van business operaties en externe calls, niet op method-niveau.

Ten tweede: vergeten om de sampling rate te configureren. Standaard stuurt de SDK alles door. Bij 10.000 requests per seconde is dat een firehose aan data die het netwerk en de storage belast.

En tot slot: tracing deployen maar er niet naar kijken. Het klinkt voor de hand liggend. Toch draait bij veel teams Jaeger maandenlang zonder dat iemand de UI opent. Bouw tracing in je incident response workflow: eerste stap bij een productie-issue is altijd de recente traces checken.

Conclusie

Distributed tracing is de derde pijler van observability, naast metrics en logging. Met OpenTelemetry als gestandaardiseerde SDK en een lightweight backend als Jaeger is de drempel laag. De investering in instrumentatie betaalt zich terug bij het eerste serieuze productie-incident waar een trace in minuten onthult wat anders uren aan debugging had gekost.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op