Skip to content

jsynowiec/otel-traceable-decorator-pattern

Repository files navigation

@Traceable — OpenTelemetry Decorator Pattern

What it is: A TypeScript method decorator that instruments class methods with OpenTelemetry spans without the need to manually manage span lifecycle in business logic.

What problem it solves: Adding distributed tracing to a service normally means wrapping every method in boilerplate code to get a tracer, start a span, set up context propagation, handle errors, and end the span. That's ~10 lines per method that all look the same and obscure the actual logic. The @Traceable decorator centralises all of it in one place.

When to use it: In Node.js/TypeScript backends where you want per-method trace spans without polluting service code with OTel plumbing. It works especially well in layered architectures (controller → service → repository) where you want automatic parent-child span relationships across layers with no manual wiring between them.


How It Works

  1. At decoration time the original method is replaced by a wrapper closure.
  2. When the wrapper is called it:
    • Reads the active OTel context and any current parent span
    • Starts a new span (named after the method by default) with code.function and code.namespace attributes
    • Calls the original method inside context.with(newSpan) so the span becomes the active context
  3. On success — sets span status OK and ends the span
  4. On failure — records the exception, sets status ERROR, ends the span, and re-throws
  5. Both sync and async (Promise-returning) methods are handled transparently
  6. Because each call sets the new span as the active context, any nested @Traceable method automatically becomes a child span of the caller

Prerequisites

The tsconfig.json must enable:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

reflect-metadata must be imported once before any decorated class is loaded — typically at the top of your application entry point:

import "reflect-metadata";

Usage

import { Traceable } from "./trace.decorator";

class OrderService {
  // Span name defaults to the method name: "placeOrder"
  // Tracer name defaults to the class name: "OrderService"
  @Traceable()
  async placeOrder(itemId: string): Promise<string> {
    // ...
  }

  // Override the span name
  @Traceable({ spanName: "db.orders.insert" })
  private async persist(order: Order): Promise<void> {
    // ...
  }

  // Override both
  @Traceable({ tracerName: "payments", spanName: "charge-card" })
  async charge(amount: number): Promise<string> {
    // ...
  }
}

Options

Option Type Default Description
tracerName string class name Name passed to trace.getTracer()
spanName string method name Name of the created span

Accessing the Active Span

Inside any decorated method the current span is available via the OTel context API:

import { context, trace } from "@opentelemetry/api";

class InventoryService {
  @Traceable()
  async checkStock(itemId: string): Promise<number> {
    const span = trace.getSpan(context.active());
    span?.setAttribute("inventory.item_id", itemId);

    const stock = await this.db.query(itemId);
    span?.setAttribute("inventory.stock", stock);
    return stock;
  }
}

Running the Demo

npm run example

Starts a full OTel trace using the console exporter and prints three spans — placeOrder as root with checkStock and process-payment as children, all sharing the same traceId.


Running Tests

npm test

Pros

  • Zero boilerplate in business logic — span lifecycle (start, status, end, exception) is fully managed by the decorator
  • Parent-child hierarchy is automaticcontext.with() makes the new span active, so any nested @Traceable call becomes a child span without any manual wiring
  • Consistent semantic attributescode.function and code.namespace are set on every span, giving uniform metadata across the codebase
  • Sync and async handled transparently — a single decorator covers both return types; no dual code paths needed in the decorator or the caller
  • Exception recording without try/catch — errors are captured, recorded on the span with ERROR status, and re-thrown, keeping business methods clean

Cons

  • Invisible span lifecycle — span start and end happen outside the method body, which can be easy to overlook during code review
  • Cannot be conditionally skipped — the decorator is applied at class definition time; there is no built-in way to opt out at runtime without removing it
  • Class instance methods only — standalone functions, arrow function properties, and static methods are not supported. Static methods silently produce a tracer named 'Function'; pass tracerName explicitly to work around it: @Traceable({ tracerName: 'MyClass' })
  • Context propagation debugging — diagnosing async context leaks or span-parenting issues requires understanding OTel's AsyncLocalStorage-based context manager internals
  • Unconditional error logging — every exception is written to console.error, including expected business errors. Replace the console.error calls in the decorator with your application logger if this produces unwanted output

NestJS Integration

Bootstrap Order

OTel must be initialized before NestJS loads any module. In main.ts:

// 1. Bootstrap tracing first — before any NestJS import
import { tracingSDK } from "./tracing";
tracingSDK.start();

// 2. Then load NestJS
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import "reflect-metadata";

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

Gotcha: Decorator Stacking Order

NestJS method decorators execute bottom-up. When stacking @Traceable with a route decorator, put @Traceable above the route decorator so the span wraps the full handler invocation:

@Traceable({ spanName: 'http.get-user' })   // applied second — outermost wrapper
@Get(':id')                                 // applied first — registers the route
async getUser(@Param('id') id: string) { ... }

Reference Code

Note: The NestJS examples are illustrative reference code. They are not compiled by tsconfig.json because @nestjs/common and related packages are not installed in this repo.

See examples/nestjs/ for:

About

Minimal example of the `@Traceable` decorator pattern

Topics

Resources

License

Stars

Watchers

Forks

Contributors