@hell-factory/trace-reaper
v2.0.1
Published
OpenTelemetry span decorator for NestJS by Hell Factory
Downloads
569
Maintainers
Readme
🪓 trace-reaper
Distributed tracing decorator for NestJS (HTTP + Microservices) powered by OpenTelemetry — brought to you by Hell Factory 🔥
✨ Features
- ✅
@Span()decorator to auto-create spans for methods (sync and async preserved) - ✅
TraceInterceptorfor HTTP and microservice handlers - ✅
ReaperClientProxy— drop-inClientProxythat injects W3C trace context into RPC payloads - ✅ Automatic context extraction from incoming headers / payload
_headers - ✅ Tracer scope
@hell-factory/trace-reaper— filter spans cleanly in any OTel backend - ✅ Zero business-logic coupling — works alongside any OpenTelemetry SDK setup
📦 Installation
npm install @hell-factory/trace-reaper
# or
pnpm add @hell-factory/trace-reaperPeer dependencies
{
"@nestjs/common": "^11.1.19",
"@nestjs/microservices": "^11.1.19",
"@opentelemetry/api": "^1.9.0",
"rxjs": "^7.8.2"
}⚠️
@nestjs/microservices>= 11.1.19 required (security patch GHSA-hpwf-8g29-85qm).
🚀 Quick start
The library exposes three primitives. Mix and match.
| Primitive | Use it for |
|---|---|
| @Span() | Wrap any method to record an OTel span |
| TraceInterceptor | NestJS interceptor — extracts context from HTTP headers / RPC _headers |
| ReaperClientProxy | Replaces ClientProxy to inject context into outgoing RPC calls |
📡 Producer side — ReaperClientProxy
Replace ClientProxyFactory.create(...) with new ReaperClientProxy(...). Every emit / send call automatically injects W3C trace headers into the payload under the _headers field.
import { Module } from '@nestjs/common';
import { Transport } from '@nestjs/microservices';
import { ReaperClientProxy } from '@hell-factory/trace-reaper';
@Module({
providers: [
{
provide: 'TODO_SERVICE',
useFactory: () =>
new ReaperClientProxy({
transport: Transport.RMQ,
options: {
urls: ['amqp://localhost:5672'],
queue: 'todo_queue',
},
}),
},
],
})
export class AppModule {}Outgoing payload shape:
// caller code
await client.emit('todo.created', { id: 'abc', title: 'buy milk' });
// what reaches the broker
{
id: 'abc',
title: 'buy milk',
_headers: { traceparent: '00-...', tracestate: '...' }
}Primitives, arrays, and
nullpayloads pass through unchanged.
📥 Consumer side
Pick one of the two: TraceInterceptor (recommended) or @Span().
Option A — TraceInterceptor (recommended)
Single registration covers HTTP and RPC handlers.
// main.ts — register globally
import { NestFactory } from '@nestjs/core';
import { TraceInterceptor } from '@hell-factory/trace-reaper';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new TraceInterceptor());
await app.listen(3000);
}
bootstrap();// or per-controller
import { UseInterceptors, Controller, Get, Param } from '@nestjs/common';
import { TraceInterceptor } from '@hell-factory/trace-reaper';
@UseInterceptors(TraceInterceptor)
@Controller('user')
export class UserController {
@Get(':id')
getUser(@Param('id') id: string) {
return { id };
}
}The interceptor:
- HTTP — extracts context from
req.headers - RPC — extracts context from
payload._headers, then strips it before the handler sees the payload - Span name =
<ControllerClass>.<handler>
Option B — @Span() per method
Useful for service-layer methods or when you want explicit span names.
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';
import { Span } from '@hell-factory/trace-reaper';
@Controller()
export class HelloController {
@Get('/hello')
@Span() // span name defaults to 'getHello'
getHello(@Req() _req: Request) {
return 'Hello World!';
}
@Get('/hi')
@Span('greet.hi') // explicit name
getHi() {
return 'Hi';
}
}import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { Span } from '@hell-factory/trace-reaper';
@Controller()
export class UserEvents {
@MessagePattern('user.created')
@Span()
handleEvent(@Payload() payload: any) {
// payload._headers has been stripped — payload is your data only
return 'OK';
}
}How @Span() extracts context
- HTTP-shape arg (
firstArg.headers && firstArg.url) → usesfirstArg.headersas carrier - RPC-shape arg (
firstArg._headers) → usesfirstArg._headers, strips it, replacesargs[0]with a clean copy (no caller mutation) - Otherwise → falls back to the active context
Sync vs async
@Span() preserves the original return shape:
class S {
@Span()
add(a: number, b: number): number { return a + b; } // returns number
@Span()
async fetch(): Promise<User> { return await db.find(); } // returns Promise<User>
}The span ends after the synchronous return, the resolved promise, or the thrown error.
🧰 API
Span(options?: string | { name?: string })
Method decorator. Creates a span around the decorated method.
| Argument | Description |
|---|---|
| string | Use this string as the span name |
| { name } | Same, object form |
| omitted | Span name = method name |
class TraceInterceptor implements NestInterceptor
Stateless interceptor. Register globally or per controller.
- HTTP context → extracts from
req.headers - RPC context → extracts from
payload._headers, strips the field - Span name =
<ControllerClass>.<handlerMethod> - Sets
SpanStatusCode.OKon success,SpanStatusCode.ERROR+recordExceptionon failure
class ReaperClientProxy extends ClientProxy
Drop-in replacement for the result of ClientProxyFactory.create(options).
| Method | Behavior |
|---|---|
| emit(pattern, data) | Injects _headers into object payloads, delegates to underlying proxy |
| send(pattern, data) | Same as above for request/response |
| connect() | Delegates |
| close() | Returns Promise<void> \| void — await it to wait for graceful disconnect |
publish,dispatchEvent, andunwrapare not implemented (NestJS does not invoke them on overriddenemit/sendpaths).
TRACE_HEADERS_FIELD
Constant '_headers' — the payload field used to carry W3C trace context across RPC.
Tracer scope
All spans are emitted under instrumentation scope:
otel.library.name = "@hell-factory/trace-reaper"
otel.library.version = "<package version>"Use this to filter spans in your backend.
🛠️ OpenTelemetry SDK setup
trace-reaper only uses the OTel API — your app must register a real SDK.
// tracing.ts — load before AppModule
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'my-service',
}),
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' }),
});
sdk.start();// main.ts
import './tracing'; // ← must be imported first
import { NestFactory } from '@nestjs/core';
// ...🔁 Migration from 1.x → 2.x
Version 2.0 is a breaking release. Highlights:
| Change | Impact |
|---|---|
| @Span() no longer wraps sync methods in a Promise | If you await-ed a sync method, remove the await. Async methods unchanged. |
| RPC carrier key is _headers (was sometimes headers) | Producers using ReaperClientProxy already write _headers. If you hand-built RPC payloads with headers, rename to _headers. |
| ReaperClientProxy.close() returns Promise<void> \| void | await client.close() for graceful shutdown. Old void callers keep working at runtime; TypeScript strict callers may need a type update. |
| Tracer scope default → @hell-factory/trace-reaper | Update backend filters / dashboards that filtered by scope.name = "default". |
| Peer @nestjs/microservices ^11.1.17 → ^11.1.19 | Security patch (GHSA-hpwf-8g29-85qm). Bump on the consumer side. |
🧪 Verifying trace propagation
End-to-end sanity check:
- Service A calls
client.send('user.create', { name: 'X' })viaReaperClientProxy. - Service B receives payload,
TraceInterceptor(or@Span()) extracts_headers. - In your backend (Jaeger / Tempo / Honeycomb / Datadog), the spans appear in one trace under scope
@hell-factory/trace-reaper.
If the spans show as two separate traces, check:
- Producer used
ReaperClientProxy(not rawClientProxyFactory)? - Consumer registered
TraceInterceptoror decorated handler with@Span()? - Both services started the OTel SDK before NestJS bootstrapped?
📜 License
MIT © Hell Factory
