|

OpenTelemetry Trace ID und Span ID mit einem Custom Span Processor in NestJS

Schon mal versucht, einen Request durch 5 Microservices zu verfolgen? Ohne eine Trace ID, die durch jeden Service fließt, ist das wie die Nadel im Heuhaufen zu suchen. Nur dass der Heuhaufen dein Log-Aggregator ist und die Nadel der Grund, warum der Checkout deines Users um 3 Uhr nachts fehlgeschlagen ist.

Lass uns eine Lösung bauen. Wir erstellen einen OpenTelemetry Span Processor, der Trace ID und Span ID aus dem eingehenden traceparent-Header ausliest — und wenn keiner vorhanden ist, werden sie automatisch generiert. Alles als wiederverwendbares NestJS SDK mit erweiterbaren Einstellungen.

Warum Trace ID und Span ID wichtig sind

In einem verteilten System kann eine einzige Benutzeraktion folgende Dienste berühren:

  1. API Gateway
  2. Auth Service
  3. Order Service
  4. Payment Service
  5. Notification Service

OpenTelemetry nutzt zwei Identifier, um alles zu verbinden:

  • Trace ID: Eine einzelne ID, die über alle Services für einen Request geteilt wird. 32 Hex-Zeichen. Das ist der Faden, der alles zusammenhält.
  • Span ID: Eine eindeutige ID für jede Operation innerhalb des Trace. 16 Hex-Zeichen. Jeder Service-Call, jede Datenbankabfrage oder HTTP-Request bekommt seinen eigenen Span.

Der traceparent-Header transportiert beides: 00-{traceId}-{spanId}-{flags}. Wenn Service A Service B aufruft, sendet er diesen Header mit. Service B führt denselben Trace fort.

Aber was passiert, wenn der erste Service in der Kette keinen traceparent erhält? Das SDK sollte einen generieren. Und was, wenn du anpassen möchtest, wie das funktioniert? Genau das bauen wir.

Der Plan

Hier ist, was wir bauen:

  • Einen Custom SpanProcessor, der sicherstellt, dass jeder Span eine Trace ID und Span ID hat
  • Wenn ein traceparent-Header vorhanden ist, wird der bestehende Trace-Context verwendet
  • Wenn nicht, generiert OTel automatisch eine neue Trace ID und Span ID
  • Trace ID und Span ID sind überall in deiner NestJS-App zugänglich
  • Vollständig konfigurierbar: Header-Namen, ID-Generierungsstrategie, Context-Propagation — alles erweiterbar
  • Als sauberes NestJS-Modul verpackt

Einrichtung

Installiere was wir brauchen:

npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace-node
npm install @opentelemetry/instrumentation-http @opentelemetry/instrumentation-nestjs-core
npm install @opentelemetry/exporter-trace-otlp-http
npm install @opentelemetry/context-async-hooks

Das Settings-Interface

Zuerst definieren wir die Konfiguration. Das macht die Library erweiterbar:

// tracing.config.ts
import { Span } from '@opentelemetry/api'

export interface TracingConfig {
  /** Service-Name für die OTel-Resource */
  serviceName: string

  /** OTLP-Exporter-Endpoint */
  exporterUrl: string

  /** Ob der Trace-Context an ausgehende Requests weitergegeben wird */
  propagate: boolean

  /** Custom Span-Attribute die jedem Span hinzugefügt werden */
  defaultAttributes: Record<string, string>

  /** Hook der aufgerufen wird, wenn ein neuer Trace gestartet wird (kein eingehender traceparent) */
  onTraceCreated?: (traceId: string, spanId: string) => void

  /** Hook der aufgerufen wird, wenn ein Trace von einem eingehenden Header fortgesetzt wird */
  onTraceContinued?: (traceId: string, spanId: string) => void

  /** Custom Span-Filter — gibt false zurück um den Span zu verwerfen */
  spanFilter?: (spanName: string) => boolean
}

export const DEFAULT_TRACING_CONFIG: TracingConfig = {
  serviceName: 'nestjs-service',
  exporterUrl: 'http://localhost:4318/v1/traces',
  propagate: true,
  defaultAttributes: {}
}

Lifecycle-Hooks wenn Traces starten gewünscht? Einfach hinzufügen. Health-Check-Spans herausfiltern? Einen Filter übergeben. Custom Attribute auf jedem Span? defaultAttributes setzen.

Der Custom Span Processor

Das ist das Herzstück. Ein Span Processor, der jeden Span mit deinen Einstellungen anreichert und dir Hooks in den Trace-Lifecycle gibt:

// trace-enrichment.processor.ts
import { Context } from '@opentelemetry/api'
import { Span, ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-node'
import { TracingConfig, DEFAULT_TRACING_CONFIG } from './tracing.config'

export class TraceEnrichmentSpanProcessor implements SpanProcessor {
  private readonly config: TracingConfig

  constructor(config?: Partial<TracingConfig>) {
    this.config = { ...DEFAULT_TRACING_CONFIG, ...config }
  }

  onStart(span: Span, parentContext: Context): void {
    const spanContext = span.spanContext()
    const traceId = spanContext.traceId
    const spanId = spanContext.spanId

    // Default-Attribute zu jedem Span hinzufügen
    for (const [key, value] of Object.entries(this.config.defaultAttributes)) {
      span.setAttribute(key, value)
    }

    // Service-Identifier hinzufügen
    span.setAttribute('service.name', this.config.serviceName)

    // Prüfe ob dieser Span einen Remote-Parent hat (eingehender traceparent-Header)
    const parentSpanContext = parentContext.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))
    const hasRemoteParent = this.hasRemoteParent(span)

    if (hasRemoteParent) {
      // Trace von Upstream fortgesetzt — traceparent-Header war vorhanden
      span.setAttribute('trace.origin', 'propagated')
      this.config.onTraceContinued?.(traceId, spanId)
    } else if (this.isRootSpan(span)) {
      // Neuer Trace gestartet — kein traceparent-Header, OTel hat die IDs generiert
      span.setAttribute('trace.origin', 'generated')
      this.config.onTraceCreated?.(traceId, spanId)
    }
  }

  onEnd(span: ReadableSpan): void {
    // Span-Filter anwenden wenn konfiguriert
    if (this.config.spanFilter && !this.config.spanFilter(span.name)) {
      // Hinweis: Filterung ist besser auf Exporter-Ebene
      // Das hier ist ein Hook für Logging/Metriken über gefilterte Spans
    }
  }

  shutdown(): Promise<void> {
    return Promise.resolve()
  }

  forceFlush(): Promise<void> {
    return Promise.resolve()
  }

  private hasRemoteParent(span: Span): boolean {
    // Prüfe ob der Parent-Context des Spans von einer Remote-Quelle kam
    const readableSpan = span as any
    return readableSpan.parentSpanId !== undefined &&
           readableSpan._spanContext?.isRemote === true
  }

  private isRootSpan(span: Span): boolean {
    const readableSpan = span as any
    return !readableSpan.parentSpanId
  }
}

Der Processor läuft bei jedem Span-Start. Hier ist die zentrale Erkenntnis: OTel behandelt die Trace ID und Span ID Generierung bereits. Wenn ein traceparent-Header reinkommt, parst die HTTP-Instrumentierung ihn und erstellt einen Child-Span mit derselben Trace ID. Wenn kein Header vorhanden ist, generiert OTel eine frische Trace ID und Span ID. Unser Processor reichert diesen Flow mit Hooks und Custom-Attributen an.

Die NestJS Middleware

Wir brauchen Middleware, um Trace ID und Span ID außerhalb von OTel zugänglich zu machen — in deinen Logs, Responses und überall sonst:

// tracing.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common'
import { Request, Response, NextFunction } from 'express'
import { trace, context, SpanStatusCode } from '@opentelemetry/api'
import { TracingConfig, DEFAULT_TRACING_CONFIG } from './tracing.config'

@Injectable()
export class TracingMiddleware implements NestMiddleware {
  private readonly config: TracingConfig

  constructor(config?: Partial<TracingConfig>) {
    this.config = { ...DEFAULT_TRACING_CONFIG, ...config }
  }

  use(req: Request, res: Response, next: NextFunction): void {
    const activeSpan = trace.getActiveSpan()

    if (activeSpan) {
      const spanContext = activeSpan.spanContext()
      const traceId = spanContext.traceId
      const spanId = spanContext.spanId

      // Trace ID und Span ID am Request leicht zugänglich machen
      req['traceId'] = traceId
      req['spanId'] = spanId

      // Als Response-Header setzen, damit Aufrufer korrelieren können
      res.setHeader('x-trace-id', traceId)
      res.setHeader('x-span-id', spanId)

      // Trace-Context für Debugging loggen
      const incomingTraceparent = req.headers['traceparent'] as string
      if (incomingTraceparent) {
        activeSpan.setAttribute('http.traceparent.incoming', incomingTraceparent)
      }
    }

    next()
  }
}

Diese Middleware macht drei Dinge:

  • Holt Trace ID und Span ID aus dem aktiven OTel-Span
  • Hängt sie an das Request-Objekt für einfachen Zugriff in Handlern
  • Setzt sie als Response-Header, damit Upstream-Aufrufer sie loggen können

Das NestJS Modul

Jetzt verdrahten wir alles als sauberes NestJS Dynamic Module:

// tracing.module.ts
import { DynamicModule, Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common'
import { TracingConfig, DEFAULT_TRACING_CONFIG } from './tracing.config'
import { TracingMiddleware } from './tracing.middleware'
import { TraceEnrichmentSpanProcessor } from './trace-enrichment.processor'

export const TRACING_CONFIG = 'TRACING_CONFIG'
export const TRACE_PROCESSOR = 'TRACE_PROCESSOR'

@Global()
@Module({})
export class TracingModule implements NestModule {
  private static config: TracingConfig = DEFAULT_TRACING_CONFIG

  static forRoot(config?: Partial<TracingConfig>): DynamicModule {
    const mergedConfig = { ...DEFAULT_TRACING_CONFIG, ...config }
    TracingModule.config = mergedConfig

    return {
      module: TracingModule,
      providers: [
        {
          provide: TRACING_CONFIG,
          useValue: mergedConfig
        },
        {
          provide: TRACE_PROCESSOR,
          useFactory: () => new TraceEnrichmentSpanProcessor(mergedConfig)
        }
      ],
      exports: [TRACING_CONFIG, TRACE_PROCESSOR]
    }
  }

  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(TracingMiddleware)
      .forRoutes('*')
  }
}

Die Decorators

Für sauberen Zugriff auf Trace ID und Span ID in deinen Handlern:

// tracing.decorators.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { trace } from '@opentelemetry/api'

export const TraceId = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest()
    return request.traceId
  }
)

export const SpanId = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest()
    return request.spanId
  }
)

Alles zusammenfügen

Richte OTel Tracing mit dem Processor in deiner Bootstrap-Datei ein. Das muss bevor NestJS startet laufen:

// tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { TraceEnrichmentSpanProcessor } from './trace-enrichment.processor'
import { W3CTraceContextPropagator } from '@opentelemetry/core'

const enrichmentProcessor = new TraceEnrichmentSpanProcessor({
  serviceName: 'order-service',
  defaultAttributes: {
    'deployment.environment': process.env.NODE_ENV || 'development'
  },
  onTraceCreated: (traceId, spanId) => {
    console.log(`Neuer Trace gestartet: ${traceId} (span: ${spanId})`)
  },
  onTraceContinued: (traceId, spanId) => {
    console.log(`Trace fortgesetzt: ${traceId} (span: ${spanId})`)
  },
  spanFilter: (name) => !name.includes('health')
})

const traceExporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces'
})

const sdk = new NodeSDK({
  spanProcessors: [
    enrichmentProcessor,
    new SimpleSpanProcessor(traceExporter)
  ],
  instrumentations: [
    new HttpInstrumentation({
      // Das ist der Schlüssel — es erfasst den traceparent-Header automatisch
      headersToSpanAttributes: {
        server: {
          requestHeaders: ['traceparent', 'tracestate']
        }
      }
    }),
    new NestInstrumentation()
  ],
  textMapPropagator: new W3CTraceContextPropagator()
})

sdk.start()

Der W3CTraceContextPropagator ist es, der das Parsen des traceparent-Headers übernimmt. Wenn ein Request mit traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 reinkommt, extrahiert OTel die Trace ID (4bf92f3577b34da6a3ce929d0e0e4736) und die Parent Span ID (00f067aa0ba902b7) und erstellt dann einen Child-Span unter demselben Trace. Kein Header? Frische Trace ID und Span ID werden generiert.

Importiere das Tracing-Setup vor NestJS:

// main.ts
import './tracing'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  await app.listen(3000)
}
bootstrap()

Registriere das Modul:

// app.module.ts
import { Module } from '@nestjs/common'
import { TracingModule } from './tracing.module'

@Module({
  imports: [
    TracingModule.forRoot({
      serviceName: 'order-service',
      propagate: true,
      defaultAttributes: {
        'team.name': 'platform'
      }
    })
  ]
})
export class AppModule {}

Verwende die Decorators in deinen Controllern:

@Controller('orders')
export class OrderController {
  private readonly logger = new Logger(OrderController.name)

  @Post()
  async createOrder(
    @TraceId() traceId: string,
    @SpanId() spanId: string,
    @Body() dto: CreateOrderDto
  ) {
    this.logger.log(
      `[trace:${traceId} span:${spanId}] Erstelle Bestellung für User ${dto.userId}`
    )

    // traceId und spanId sind auch automatisch auf jedem OTel Span
    return this.orderService.create(dto)
  }
}

Wie der traceparent-Header funktioniert

Wenn Service A Service B aufruft, ist der Ablauf:

Service A (kein eingehender traceparent)
  → OTel generiert traceId: abc123... und spanId: def456...
  → Ausgehender Request fügt Header hinzu: traceparent: 00-abc123...-def456...-01

Service B (empfängt traceparent: 00-abc123...-def456...-01)
  → OTel parst den Header
  → Erstellt Span mit gleicher traceId: abc123... und neuer spanId: ghi789...
  → Parent spanId ist def456...
  → Unser Processor feuert den onTraceContinued Callback

Service C (aufgerufen von Service B)
  → Gleiche traceId: abc123... fließt durch
  → Jeder Span in Jaeger/Zipkin erscheint unter einem Trace

Alle Services teilen dieselbe Trace ID. Jeder bekommt seine eigene Span ID. So erhältst du das vollständige Bild in deinem Trace-Backend.

Einstellungen erweitern

Die wahre Stärke ist die Erweiterbarkeit. Hier sind einige Beispiele:

Business-Kontext zu jedem Span hinzufügen:

TracingModule.forRoot({
  serviceName: 'payment-service',
  defaultAttributes: {
    'deployment.environment': 'production',
    'deployment.region': 'eu-west-1',
    'team.name': 'payments',
    'service.version': process.env.APP_VERSION || '0.0.0'
  }
})

Custom Lifecycle-Hooks für Metriken:

import { Counter } from 'prom-client'

const newTraces = new Counter({
  name: 'traces_created_total',
  help: 'Gesamtanzahl der erstellten neuen Traces'
})

const continuedTraces = new Counter({
  name: 'traces_continued_total',
  help: 'Gesamtanzahl der von Upstream fortgesetzten Traces'
})

TracingModule.forRoot({
  serviceName: 'api-gateway',
  onTraceCreated: (traceId) => {
    newTraces.inc()
  },
  onTraceContinued: (traceId) => {
    continuedTraces.inc()
  }
})

Störende Spans herausfiltern:

TracingModule.forRoot({
  serviceName: 'order-service',
  spanFilter: (name) => {
    // Health Checks und Metrics-Endpoints verwerfen
    const ignore = ['/health', '/metrics', '/ready', '/live']
    return !ignore.some(path => name.includes(path))
  }
})

Testen

Überprüfe, ob Trace-Propagation korrekt funktioniert:

describe('Tracing', () => {
  let app: INestApplication

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TracingModule.forRoot({
          serviceName: 'test-service'
        })
      ],
      controllers: [TestController]
    }).compile()

    app = module.createNestApplication()
    await app.init()
  })

  it('should return trace ID and span ID in response headers', async () => {
    const response = await request(app.getHttpServer())
      .get('/test')
      .expect(200)

    expect(response.headers['x-trace-id']).toBeDefined()
    expect(response.headers['x-trace-id']).toMatch(/^[0-9a-f]{32}$/)
    expect(response.headers['x-span-id']).toBeDefined()
    expect(response.headers['x-span-id']).toMatch(/^[0-9a-f]{16}$/)
  })

  it('should continue trace from incoming traceparent header', async () => {
    const traceId = '4bf92f3577b34da6a3ce929d0e0e4736'
    const parentSpanId = '00f067aa0ba902b7'
    const traceparent = `00-${traceId}-${parentSpanId}-01`

    const response = await request(app.getHttpServer())
      .get('/test')
      .set('traceparent', traceparent)
      .expect(200)

    // Gleiche Trace ID sollte fortgesetzt werden
    expect(response.headers['x-trace-id']).toBe(traceId)
    // Span ID sollte anders sein (neuer Span im selben Trace)
    expect(response.headers['x-span-id']).not.toBe(parentSpanId)
  })

  it('should generate new trace when no traceparent is provided', async () => {
    const response1 = await request(app.getHttpServer())
      .get('/test')
      .expect(200)

    const response2 = await request(app.getHttpServer())
      .get('/test')
      .expect(200)

    // Jeder Request ohne traceparent sollte seine eigene Trace ID bekommen
    expect(response1.headers['x-trace-id']).not.toBe(
      response2.headers['x-trace-id']
    )
  })
})

Was ich gelernt habe

OTel übernimmt die ID-Generierung — nutze das: Erfinde das Rad nicht neu. Der W3C Trace Context Propagator parst traceparent-Header und OTels IdGenerator erstellt neue Trace IDs und Span IDs wenn nötig. Deine Aufgabe ist anreichern, nicht ersetzen.

Trace ID = die gesamte Reise, Span ID = ein Schritt: Eine Trace ID verbindet 50 Spans über 5 Services zu einer Geschichte. Eine Span ID ist ein Kapitel. Diesen Unterschied zu verstehen ist entscheidend.

Der traceparent-Header ist der Vertrag: 00-{traceId}-{spanId}-{flags} ist der W3C-Standard. Jeder Service, der diesen Header versteht, kann am Trace teilnehmen, auch nicht-Node.js-Services.

Config sollte erweiterbar sein, nicht starr: Mit Partial<Config> und dem Spread-Operator überschreiben Nutzer nur, was sie brauchen. Sinnvolle Defaults erledigen den Rest.

Middleware läuft vor Interceptors: Der Trace-Context muss in der Middleware zugänglich sein — bevor deine Business-Logik läuft. Die Reihenfolge ist wichtig.

Fazit

Trace ID und Span ID sind das Rückgrat von Distributed Tracing. OTel generiert sie, wenn kein traceparent-Header existiert und setzt den Trace fort wenn einer vorhanden ist. Unser Custom Span Processor reichert diesen Flow mit Lifecycle-Hooks, Default-Attributen und erweiterbarer Konfiguration an — alles verpackt als sauberes NestJS-Modul.

Die zentrale Erkenntnis? Kämpfe nicht gegen das OTel SDK — erweitere es. Ein Custom Span Processor ist der richtige Ort für Querschnittsbelange. Lass OTel die Trace-Context-Propagation übernehmen und fokussiere deinen Code darauf, diese Traces nützlich zu machen.

Keep pushing forward and savor every step of your coding journey.