Tracing
Wrap controller handlers, use cases and arbitrary methods in OpenTelemetry spans.
Every controller handler runs inside an OpenTelemetry span automatically. Use cases decorated with @DefineUseCase add a nested span around execute. You can wrap any other method with @TraceSpan to keep the call graph complete.
exjs-controllers depends only on @opentelemetry/api — bringing the SDK (NodeSDK, exporters, processors) is your responsibility. Without an SDK installed, span creation is a no-op.
Automatic spans
| Span | Attributes |
|---|---|
ControllerName.handlerName (per request) | http.method, http.route, http.controller.name, http.controller.handler, http.handler.name, http.handler.method |
UseCaseName.execute (per @DefineUseCase) | exjs.handler.name, exjs.handler.method, exjs.use_case.name, exjs.use_case.method |
BaseSchema.validate (per instance.validate) | (defaults) |
Exceptions thrown inside the wrapped function are recorded on the span and the status is marked as ERROR before being rethrown — so failures show up cleanly in your tracing backend.
@TraceSpan(name?, options?)
Wrap any method with a custom span. Works on controllers, services, repositories or use cases.
import { TraceSpan } from 'exjs-controllers/decorators/TraceSpan'
class UsersRepository {
@TraceSpan('UsersRepository.findAll')
async findAll() { /* ... */ }
@TraceSpan('UsersRepository.findOne', {
attributes: { 'db.system': 'postgresql' }
})
async findOne(id: string) { /* ... */ }
}If name is omitted, the span name defaults to <ClassName>.<methodName>.
runWithSpan and runWithControllerSpan
For ad-hoc wrapping, use the helpers directly.
import {
runWithSpan,
runWithControllerSpan
} from 'exjs-controllers/observability/tracing'
await runWithSpan(
{ name: 'syncProducts', attributes: { 'job.kind': 'scheduled' } },
() => syncProducts()
)Both helpers preserve sync vs async behaviour — they return the original value type, end the span on success, and on failure record the exception and mark SpanStatusCode.ERROR.
Bringing an OpenTelemetry SDK
Install @opentelemetry/sdk-node (or the browser equivalent) and start it before configureApplication:
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
})
})
await sdk.start()import './telemetry' // must come first
import { createApplication } from 'exjs-controllers/http/application'
import { configureApplication } from 'exjs-controllers/config/configureApplication'
const app = createApplication()
await configureApplication(app, { /* ... */ })
app.listen(3000)Tying logs and traces together
The HTTP logger emits correlationId per request, and the logger automatically stamps traceId / spanId onto every log line whenever an OpenTelemetry span is active:
- Logs emitted inside a handler get the current span's context via a Pino
mixinthat readstrace.getActiveSpan()at log time (so nested spans surface their ownspanId). - The HTTP access log is emitted on the response
closeevent — after the span has ended and outside the async context — so the framework captures the request's outermost span context (the controller span) into the request log context and reuses it for that final line.
This requires a registered OpenTelemetry SDK/provider (see above). With only @opentelemetry/api and no provider, spans are no-ops with an invalid context, so traceId / spanId are omitted (they show as - in the pretty formatter). The pretty log formatter surfaces both when present, so you can pivot from a log line straight to your tracing UI.