OpenTelemetry Trace ID and Span ID with a Custom Span Processor in NestJS
Ever tried tracing a request through 5 microservices? Without a trace ID flowing through every service, it’s like finding a needle in a haystack. Except the haystack is your log aggregator and the needle is why your user’s checkout failed at 3am.
Let’s build a solution. We’ll create an OpenTelemetry span processor that picks up the trace ID and span ID from the incoming traceparent header — and if there isn’t one, generates them automatically. All as a reusable NestJS SDK with extendable settings.
Why trace ID and span ID matter
In a distributed system, a single user action might touch:
- API Gateway
- Auth Service
- Order Service
- Payment Service
- Notification Service
OpenTelemetry uses two identifiers to connect the dots:
- Trace ID: A single ID shared across all services for one request. 32 hex characters. This is the thread that ties everything together.
- Span ID: A unique ID for each operation within the trace. 16 hex characters. Each service call, database query, or HTTP request gets its own span.
The traceparent header carries both: 00-{traceId}-{spanId}-{flags}. When Service A calls Service B, it sends this header. Service B continues the same trace.
But what if the first service in the chain doesn’t receive a traceparent? The SDK should generate one. And what if you need to customize how that works? That’s what we’re building.
The plan
Here’s what we’re building:
- A custom
SpanProcessorthat ensures every span has a trace ID and span ID - If a
traceparentheader is present, the existing trace context is used - If not, OTel generates a new trace ID and span ID automatically
- The trace ID and span ID are accessible everywhere in your NestJS app
- Fully configurable: header names, ID generation strategy, context propagation — all extendable
- Packaged as a clean NestJS module
Setting up
Install what we need:
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
The settings interface
First, let’s define the configuration. This is what makes the library extendable:
// tracing.config.ts
import { Span } from '@opentelemetry/api'
export interface TracingConfig {
/** Service name for OTel resource */
serviceName: string
/** OTLP exporter endpoint */
exporterUrl: string
/** Whether to propagate trace context to outgoing requests */
propagate: boolean
/** Custom span attributes to add to every span */
defaultAttributes: Record<string, string>
/** Hook called when a new trace is started (no incoming traceparent) */
onTraceCreated?: (traceId: string, spanId: string) => void
/** Hook called when a trace is continued from an incoming header */
onTraceContinued?: (traceId: string, spanId: string) => void
/** Custom span filter — return false to drop the span */
spanFilter?: (spanName: string) => boolean
}
export const DEFAULT_TRACING_CONFIG: TracingConfig = {
serviceName: 'nestjs-service',
exporterUrl: 'http://localhost:4318/v1/traces',
propagate: true,
defaultAttributes: {}
}
Want lifecycle hooks when traces start? Add them. Want to filter out health check spans? Pass a filter. Want custom attributes on every span? Set defaultAttributes.
The custom span processor
This is the core. A span processor that enriches every span with your settings and gives you hooks into the trace lifecycle:
// 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
// Add default attributes to every span
for (const [key, value] of Object.entries(this.config.defaultAttributes)) {
span.setAttribute(key, value)
}
// Add service identifier
span.setAttribute('service.name', this.config.serviceName)
// Check if this span has a remote parent (incoming traceparent header)
const parentSpanContext = parentContext.getValue(Symbol.for('OpenTelemetry Context Key SPAN'))
const hasRemoteParent = this.hasRemoteParent(span)
if (hasRemoteParent) {
// Trace continued from upstream — traceparent header was present
span.setAttribute('trace.origin', 'propagated')
this.config.onTraceContinued?.(traceId, spanId)
} else if (this.isRootSpan(span)) {
// New trace started — no traceparent header, OTel generated the IDs
span.setAttribute('trace.origin', 'generated')
this.config.onTraceCreated?.(traceId, spanId)
}
}
onEnd(span: ReadableSpan): void {
// Apply span filter if configured
if (this.config.spanFilter && !this.config.spanFilter(span.name)) {
// Note: Filtering is better done at the exporter level
// This is a hook for logging/metrics about filtered spans
}
}
shutdown(): Promise<void> {
return Promise.resolve()
}
forceFlush(): Promise<void> {
return Promise.resolve()
}
private hasRemoteParent(span: Span): boolean {
// Check if the span's parent context came from a remote source
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
}
}
The processor runs on every span start. Here’s the key insight: OTel already handles the trace ID and span ID generation. If a traceparent header comes in, the HTTP instrumentation parses it and creates a child span with the same trace ID. If no header is present, OTel generates a fresh trace ID and span ID. Our processor enriches this flow with hooks and custom attributes.
The NestJS middleware
We need middleware to make the trace ID and span ID accessible outside of OTel — in your logs, responses, and anywhere else:
// 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
// Make trace ID and span ID easily accessible on the request
req['traceId'] = traceId
req['spanId'] = spanId
// Set as response headers so callers can correlate
res.setHeader('x-trace-id', traceId)
res.setHeader('x-span-id', spanId)
// Log the trace context for debugging
const incomingTraceparent = req.headers['traceparent'] as string
if (incomingTraceparent) {
activeSpan.setAttribute('http.traceparent.incoming', incomingTraceparent)
}
}
next()
}
}
This middleware does three things:
- Grabs the trace ID and span ID from the active OTel span
- Attaches them to the request object for easy access in handlers
- Sets them as response headers so upstream callers can log them
The NestJS module
Now let’s wire it all together as a proper 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('*')
}
}
The decorators
For clean access to trace ID and span ID in your handlers:
// 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
}
)
Putting it all together
Set up OTel tracing with the processor in your bootstrap file. This must run before NestJS boots:
// 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(`New trace started: ${traceId} (span: ${spanId})`)
},
onTraceContinued: (traceId, spanId) => {
console.log(`Trace continued: ${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({
// This is key — it captures the traceparent header automatically
headersToSpanAttributes: {
server: {
requestHeaders: ['traceparent', 'tracestate']
}
}
}),
new NestInstrumentation()
],
textMapPropagator: new W3CTraceContextPropagator()
})
sdk.start()
The W3CTraceContextPropagator is what handles the traceparent header parsing. When a request comes in with traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, OTel extracts the trace ID (4bf92f3577b34da6a3ce929d0e0e4736) and parent span ID (00f067aa0ba902b7), then creates a child span under the same trace. No header? Fresh trace ID and span ID get generated.
Import the tracing setup before 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()
Register the module:
// 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 {}
Use the decorators in your controllers:
@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}] Creating order for user ${dto.userId}`
)
// traceId and spanId are also on every OTel span automatically
return this.orderService.create(dto)
}
}
How the traceparent header works
When Service A calls Service B, the flow is:
Service A (no incoming traceparent)
→ OTel generates traceId: abc123... and spanId: def456...
→ Outgoing request adds header: traceparent: 00-abc123...-def456...-01
Service B (receives traceparent: 00-abc123...-def456...-01)
→ OTel parses the header
→ Creates span with same traceId: abc123... and new spanId: ghi789...
→ Parent spanId is def456...
→ Our processor fires onTraceContinued callback
Service C (called by Service B)
→ Same traceId: abc123... flows through
→ Every span in Jaeger/Zipkin shows up under one trace
All services share the same trace ID. Each gets its own span ID. That’s how you get the full picture in your trace backend.
Extending the settings
The real power is extensibility. Here are some examples:
Add business context to every span:
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 for metrics:
import { Counter } from 'prom-client'
const newTraces = new Counter({
name: 'traces_created_total',
help: 'Total number of new traces created'
})
const continuedTraces = new Counter({
name: 'traces_continued_total',
help: 'Total number of traces continued from upstream'
})
TracingModule.forRoot({
serviceName: 'api-gateway',
onTraceCreated: (traceId) => {
newTraces.inc()
},
onTraceContinued: (traceId) => {
continuedTraces.inc()
}
})
Filter out noisy spans:
TracingModule.forRoot({
serviceName: 'order-service',
spanFilter: (name) => {
// Drop health checks and metrics endpoints
const ignore = ['/health', '/metrics', '/ready', '/live']
return !ignore.some(path => name.includes(path))
}
})
Testing it
Verify trace propagation works correctly:
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)
// Same trace ID should be continued
expect(response.headers['x-trace-id']).toBe(traceId)
// Span ID should be different (new span in the same 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)
// Each request without traceparent should get its own trace ID
expect(response1.headers['x-trace-id']).not.toBe(
response2.headers['x-trace-id']
)
})
})
What I learned
OTel handles ID generation — lean on it: Don’t reinvent the wheel. The W3C Trace Context propagator parses traceparent headers and OTel’s IdGenerator creates new trace IDs and span IDs when needed. Your job is to enrich, not replace.
Trace ID = the whole journey, Span ID = one step: A trace ID ties 50 spans across 5 services into one story. A span ID is one chapter. Understanding this distinction is key.
The traceparent header is the contract: 00-{traceId}-{spanId}-{flags} is the W3C standard. Every service that speaks this header can participate in the trace, even non-Node.js ones.
Config should be extendable, not rigid: Using Partial<Config> and spread operator means users override only what they need. Sensible defaults handle the rest.
Middleware runs before interceptors: The trace context must be accessible in middleware — before your business logic runs. Order matters.
Wrapping up
Trace ID and span ID are the backbone of distributed tracing. OTel generates them when no traceparent header exists and continues the trace when one does. Our custom span processor enriches this flow with lifecycle hooks, default attributes, and extensible configuration — all packaged as a clean NestJS module.
The key insight? Don’t fight the OTel SDK — extend it. A custom span processor is the right place for cross-cutting concerns. Let OTel handle the trace context propagation, and focus your code on making those traces useful.
Keep pushing forward and savor every step of your coding journey.
