OpenTelemetry integration for AsenaJS — automatic HTTP tracing, method-level auto-tracing, metrics, and distributed tracing support.
A single request automatically produces a full waterfall trace:
GET /api/users (SERVER)
└─ UserController.list (INTERNAL)
└─ UserService.getAll (INTERNAL)
bun add @asenajs/asena-otel @opentelemetry/api @opentelemetry/resources @opentelemetry/sdk-trace-base @opentelemetry/sdk-metrics @opentelemetry/semantic-conventions @opentelemetry/context-async-hooksCreate a class that extends OtelTracingPostProcessor and apply the @Otel decorator with your configuration. Asena automatically discovers and initializes it during bootstrap.
// src/otel/AppOtel.ts
import { Otel, OtelTracingPostProcessor } from '@asenajs/asena-otel';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
@Otel({
serviceName: 'my-app',
serviceVersion: '1.0.0',
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces',
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: 'http://localhost:4318/v1/metrics',
}),
}),
autoTrace: {
services: true, // auto-trace @Service methods
controllers: true, // auto-trace @Controller methods
},
})
export class AppOtel extends OtelTracingPostProcessor {}Add OtelTracingMiddleware to your config's globalMiddlewares(). This is required so that all HTTP requests are traced automatically.
import { Config } from '@asenajs/asena/decorators';
import { ConfigService, type Context, HttpException } from '@asenajs/ergenecore';
import { OtelTracingMiddleware } from '@asenajs/asena-otel';
import { AppCorsMiddleware } from '../middlewares/AppCorsMiddleware';
@Config()
export class AppConfig extends ConfigService {
public globalMiddlewares() {
return [
OtelTracingMiddleware, // traces all HTTP requests automatically
AppCorsMiddleware,
];
}
public onError(error: Error, context: Context) {
if (error instanceof HttpException) {
return context.send(error.body, error.status);
}
return context.send({ error: 'Internal Server Error' }, 500);
}
}That's it. Asena's IoC container automatically discovers AppOtel, OtelService, and OtelTracingMiddleware. All HTTP requests are traced, service methods are auto-traced, and metrics are collected — without changing any business logic.
Configures an OtelTracingPostProcessor subclass with OpenTelemetry options. Apply it to a class extending OtelTracingPostProcessor:
import { Otel, OtelTracingPostProcessor } from '@asenajs/asena-otel';
@Otel({
serviceName: 'my-app',
traceExporter: exporter,
autoTrace: { services: true, controllers: true },
})
export class AppOtel extends OtelTracingPostProcessor {}The decorator stores the options as metadata and applies @PostProcessor() automatically. During bootstrap, @PostConstruct() in OtelTracingPostProcessor reads the metadata and initializes the OpenTelemetry SDK — tracer provider, meter provider, context manager, and shutdown hooks.
Injectable @Service that provides access to OpenTelemetry tracer and meter. Use it for custom spans and distributed tracing. Asena automatically discovers and registers it.
import { Inject } from '@asenajs/asena/decorators/ioc';
import type { OtelService } from '@asenajs/asena-otel';
@Service()
export class OrderService {
@Inject('OtelService')
private otelService: OtelService;
async processOrder(orderId: string) {
return this.otelService.withSpan('process-order', async (span) => {
span.setAttribute('order.id', orderId);
// ... business logic
return { success: true };
});
// span automatically ends, errors are recorded
}
}API:
tracer— OpenTelemetryTracerinstancemeter— OpenTelemetryMeterinstancewithSpan(name, fn)— creates a span, sets OK/ERROR status, records exceptions, ends span automaticallygetActiveSpan()— returns current active span (or undefined)injectTraceContext(headers?)— injects W3Ctraceparentheader into a headers object for distributed tracing (see Outgoing Request Context Propagation)
Expression injection (for direct tracer/meter access):
@Inject('OtelService', (s) => s.tracer)
private tracer: Tracer;
@Inject('OtelService', (s) => s.meter)
private meter: Meter;@Middleware that automatically traces all HTTP requests. Register in your Config's globalMiddlewares() to enable request tracing.
Creates for each request:
- A
SERVERspan named"{METHOD} {PATH}"(e.g.,GET /api/users) - Attributes:
http.request.method,url.path,http.route(after route matching),http.response.status_code - Metrics:
http.server.request.count(Counter),http.server.request.duration(Histogram) - Extracts W3C
traceparentheader from incoming requests for distributed tracing
@PostProcessor that wraps Service and Controller methods with tracing spans via JavaScript Proxy.
- Creates
INTERNALspans named"{ClassName}.{methodName}"(e.g.,UserService.getAll) - Spans are automatically children of the HTTP span (proper waterfall hierarchy)
- Controlled via
autoTraceconfig in the@Oteldecorator options - Skips private methods (
_prefix), constructors, and Symbol properties
interface AsenaOtelOptions {
serviceName: string; // Required: identifies your service
serviceVersion?: string; // Optional: defaults to '0.0.0'
traceExporter: SpanExporter; // Required: where to send spans
metricReader?: MetricReader; // Optional: enables metrics collection
autoTrace?: AutoTraceConfig; // Optional: auto-trace settings
sampler?: Sampler; // Optional: custom sampling strategy
ignoreRoutes?: string[]; // Optional: routes to exclude from tracing
}
interface AutoTraceConfig {
services?: boolean; // auto-trace @Service methods (default: false)
controllers?: boolean; // auto-trace @Controller methods (default: false)
}Use ratioBasedSampler() for production environments to control trace volume:
import { Otel, OtelTracingPostProcessor, ratioBasedSampler } from '@asenajs/asena-otel';
@Otel({
serviceName: 'my-app',
traceExporter: exporter,
sampler: ratioBasedSampler(0.1), // sample 10% of traces
autoTrace: { services: true, controllers: true },
})
export class AppOtel extends OtelTracingPostProcessor {}ratioBasedSampler(ratio) creates a ParentBasedSampler wrapping TraceIdRatioBasedSampler. Root spans are sampled at the given ratio (0.0–1.0); child spans respect the parent's decision.
Use ignoreRoutes to skip tracing on specific paths (e.g., health checks, metrics endpoints):
@Otel({
serviceName: 'my-app',
traceExporter: exporter,
ignoreRoutes: ['/health', '/metrics', '/admin/*'],
})
export class AppOtel extends OtelTracingPostProcessor {}- Exact match:
/healthmatches only/health - Wildcard suffix:
/admin/*matches/admin/and all sub-paths
Ignored routes produce no spans and no metrics.
@asenajs/asena-otel automatically traces incoming HTTP requests via OtelTracingMiddleware (extracts W3C traceparent header). However, outgoing HTTP calls (e.g., fetch to another service) are not automatically instrumented.
Use OtelService.injectTraceContext() to manually propagate trace context to downstream services:
@Service()
export class PaymentClient {
@Inject('OtelService')
private otelService: OtelService;
async charge(payload: ChargeRequest) {
// injectTraceContext() adds the W3C traceparent header
const headers = this.otelService.injectTraceContext({
'Content-Type': 'application/json',
});
const res = await fetch('http://payment-service/api/charge', {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
return res.json();
}
}For full visibility, combine with withSpan() to create a dedicated span for the outgoing call:
async charge(payload: ChargeRequest) {
return this.otelService.withSpan('call-payment-service', async (span) => {
span.setAttribute('service.target', 'payment-service');
const headers = this.otelService.injectTraceContext();
const res = await fetch('http://payment-service/api/charge', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return res.json();
});
}This writes a traceparent header in the format 00-{traceId}-{spanId}-{traceFlags}. The downstream service extracts this header to continue the same trace, enabling end-to-end distributed tracing across microservices.
Use InMemorySpanExporter and InMemoryMetricExporter for testing:
import { Otel, OtelTracingPostProcessor } from '@asenajs/asena-otel';
import { InMemorySpanExporter } from '@opentelemetry/sdk-trace-base';
import { InMemoryMetricExporter, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
const spanExporter = new InMemorySpanExporter();
const metricExporter = new InMemoryMetricExporter();
const metricReader = new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 100,
});
@Otel({
serviceName: 'test-service',
traceExporter: spanExporter,
metricReader,
autoTrace: { services: true, controllers: true },
})
export class TestOtel extends OtelTracingPostProcessor {}After making requests, flush spans (BatchSpanProcessor is lazy):
import { trace } from '@opentelemetry/api';
const provider = trace.getTracerProvider() as any;
await provider.forceFlush?.();
const spans = spanExporter.getFinishedSpans();For metrics, wait for periodic export then read from exporter:
await metricReader.forceFlush();
const metrics = metricExporter.getMetrics();In OpenTelemetry SDK v2, use parentSpanContext (not parentSpanId):
const httpSpan = spans.find(s => s.kind === SpanKind.SERVER);
const serviceSpan = spans.find(s => s.name.includes('UserService'));
// Same trace
expect(serviceSpan.spanContext().traceId).toBe(httpSpan.spanContext().traceId);
// Parent-child link (SDK v2)
expect(serviceSpan.parentSpanContext?.spanId).toBe(httpSpan.spanContext().spanId);MIT