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:
- API Gateway
- Auth Service
- Order Service
- Payment Service
- 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.
