OpenTelemetry with KickJS
KickJS doesn't ship a first-party OTel package — the SDK has its own opinionated lifecycle (it registers process.on('SIGTERM') to flush spans before exit, owns its own context propagation, and pulls heavy dependencies). Instead, this guide shows how to mount your own OTel adapter that cooperates with KickJS's lifecycle and ALS-backed request store.
Two SIGTERM handlers race each other
The OpenTelemetry Node SDK installs process.on('SIGTERM', ...) to call sdk.shutdown() and flush in-flight spans. KickJS does the same to call app.shutdown(). Both then call process.exit(0). Whichever returns first wins, the loser truncates.
Set processHooks: 'errors-only' on bootstrap() to let the OTel SDK own shutdown. KickJS keeps the uncaughtException / unhandledRejection loggers but skips the signal handlers; the SDK calls app.shutdown() itself as part of its own teardown sequence.
Setup
Install the upstream SDK and exporters you actually want:
pnpm add @opentelemetry/sdk-node \
@opentelemetry/api \
@opentelemetry/instrumentation-http \
@opentelemetry/exporter-trace-otlp-httpAdapter — own the OTel SDK lifecycle
// src/adapters/otel.adapter.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
import { defineAdapter, type AdapterContext } from '@forinda/kickjs'
export interface OtelAdapterOptions {
serviceName: string
serviceVersion?: string
/** OTLP collector endpoint. Default: `http://localhost:4318/v1/traces`. */
endpoint?: string
}
export const OtelAdapter = defineAdapter<OtelAdapterOptions>({
name: 'OtelAdapter',
build: (config) => {
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.serviceName,
[ATTR_SERVICE_VERSION]: config.serviceVersion ?? '0.0.0',
}),
traceExporter: new OTLPTraceExporter({
url: config.endpoint ?? 'http://localhost:4318/v1/traces',
}),
instrumentations: [new HttpInstrumentation()],
})
return {
// Start the SDK BEFORE the framework loads modules so the
// HTTP instrumentation hooks Express on require.
async beforeStart(_ctx: AdapterContext) {
await sdk.start()
},
// KickJS calls every adapter's shutdown via Promise.allSettled,
// so the SDK flush happens cooperatively with whatever the rest
// of the app needs to clean up.
async shutdown() {
await sdk.shutdown()
},
}
},
})Bootstrap — opt out of the framework's signal handlers
// src/index.ts
import 'reflect-metadata'
import './config'
import { bootstrap } from '@forinda/kickjs'
import { modules } from './modules'
import { OtelAdapter } from './adapters/otel.adapter'
export const app = await bootstrap({
modules,
adapters: [
OtelAdapter({ serviceName: 'my-api', serviceVersion: '1.0.0' }),
],
// Critical: let the OTel SDK own SIGTERM. KickJS keeps the
// uncaughtException / unhandledRejection loggers but skips
// signal registration so the SDK's own handler can flush
// spans without racing us to process.exit().
processHooks: 'errors-only',
})The OTel SDK's signal handler calls sdk.shutdown(), which inside completes via the SDK's own choreography. Because KickJS only emitted the error loggers, there's no second handler racing for process.exit(0).
Per-route attributes from a Context Contributor
Use the contributor pipeline to set OTel span attributes per request — typed via ContextMeta, available from any handler / service:
// src/contributors/trace.context.ts
import { trace } from '@opentelemetry/api'
import { defineHttpContextDecorator } from '@forinda/kickjs'
declare module '@forinda/kickjs' {
interface ContextMeta {
trace: { traceId: string; spanId: string }
}
}
export const AttachTrace = defineHttpContextDecorator({
key: 'trace',
resolve: (_ctx) => {
const span = trace.getActiveSpan()
const sc = span?.spanContext()
return {
traceId: sc?.traceId ?? '',
spanId: sc?.spanId ?? '',
}
},
})Mount it globally:
bootstrap({
modules,
adapters: [OtelAdapter({ serviceName: 'my-api' })],
contributors: [AttachTrace.registration],
processHooks: 'errors-only',
})Now any handler can ctx.get('trace') and any service can getRequestValue('trace') — the trace/span IDs land in your application logs, audit records, error reports, etc., without threading them through every method signature.
Redacting sensitive attributes
The previous shipped adapter did this for you. Inline it as a tiny helper — keeps the contract narrow and visible:
// src/lib/redact.ts
const SENSITIVE = new Set(['authorization', 'cookie', 'set-cookie', 'x-api-key', 'password'])
export function redact<T extends Record<string, unknown>>(attrs: T): T {
const out = { ...attrs }
for (const key of Object.keys(out)) {
if (SENSITIVE.has(key.toLowerCase())) {
out[key] = '[REDACTED]' as unknown as T[Extract<keyof T, string>]
}
}
return out
}Call redact() before span.setAttributes(...) anywhere you add headers/body to a span.
DevTools integration
Surface SDK state and recent span counts on the DevTools dashboard via the introspect() slot — no separate wiring needed:
import { defineAdapter } from '@forinda/kickjs'
import type { IntrospectionSnapshot } from '@forinda/kickjs-devtools-kit'
export const OtelAdapter = defineAdapter<OtelAdapterOptions>({
name: 'OtelAdapter',
build: (config) => {
let spans = 0
let exporterErrors = 0
// (increment these from a SpanProcessor wrapper or exporter hook)
return {
// ... beforeStart / shutdown as above
introspect(): IntrospectionSnapshot {
return {
protocolVersion: 1,
name: 'OtelAdapter',
kind: 'adapter',
state: {
serviceName: config.serviceName,
endpoint: config.endpoint ?? 'http://localhost:4318/v1/traces',
},
metrics: { spansEmitted: spans, exporterErrors },
}
},
}
},
})The DevTools topology tab will show OtelAdapter alongside other adapters with a live counter for spansEmitted / exporterErrors. Adopt devtoolsTabs() if you want a dedicated panel (e.g. a button to manually flush spans for debugging).
What you give up by going BYO
The previous @forinda/kickjs-otel adapter pre-wired three things you'd otherwise inline:
- Auto-spans for each HTTP request — replaced by
HttpInstrumentation()in the SDK setup above. - Per-route latency histograms — add a manual
meter.createHistogram(...)and record onafterRoutesmiddleware. ignoreRoutesarray — replace withHttpInstrumentation'signoreIncomingRequestHookoption.
Everything else (exporters, propagators, samplers, instrumentations) was always pass-through to the upstream SDK — the wrapper added no value there.
Related
- Adapters —
defineAdapterfactory reference - Context Decorators — typed per-request values
- Lifecycle — adapter hook order +
processHookssemantics - OpenTelemetry Node SDK docs