@setu-ts/telemetry-plugin
OpenTelemetry distributed tracing. Registers an ITelemetryService under CAPABILITIES.TELEMETRY
('telemetry') and a request-span middleware at priority 30.
Without an exporter the plugin runs in noop mode. With an exporter, install the optional peers for
the features you enable; the SDK is loaded during registration.
Install peers within the ranges declared by this package’s peerDependencies, where this package is
installed — usually the root in an npm/Yarn/Bun workspace. A peer nested only under a member cannot
be loaded by a root-hoisted plugin. setu add telemetry records the four console-exporter peers in
both manifests in a Node or Bun Setu workspace. See
Optional peers in a workspace.
Installation
| Feature | Optional peer packages loaded |
|---|---|
| Any exporter | @opentelemetry/sdk-trace-base, @opentelemetry/resources, @opentelemetry/api |
exporter: 'console' | The common peers above; the console exporter is in @opentelemetry/sdk-trace-base |
exporter: 'otlp' | Common peers plus @opentelemetry/exporter-trace-otlp-http |
contextPropagation: true (default with an exporter) | @opentelemetry/context-async-hooks |
instrumentations.http | @opentelemetry/instrumentation-http |
instrumentations.fetch | @opentelemetry/instrumentation-undici |
instrumentations.ioredis | @opentelemetry/instrumentation-ioredis |
instrumentations.redis | @opentelemetry/instrumentation-redis (^0.69.0, the first release supporting node-redis v6) |
instrumentations.amqplib | @opentelemetry/instrumentation-amqplib |
instrumentations.kafkajs | @opentelemetry/instrumentation-kafkajs |
A common OTLP setup with nested spans:
npm install @setu-ts/telemetry-plugin @opentelemetry/sdk-trace-base @opentelemetry/resources @opentelemetry/api @opentelemetry/exporter-trace-otlp-http @opentelemetry/context-async-hooks
A missing common SDK peer makes app.start() reject with OptionalDependencyUnavailableError
naming the package; it does not switch to noop. For example, without the SDK on Node:
Optional dependency '@opentelemetry/sdk-trace-base' could not be loaded by '@setu-ts/telemetry-plugin'. Install it where '@setu-ts/telemetry-plugin' is installed, in the version range that package declares for it — in an npm, Yarn or Bun workspace that is usually the workspace root, not the member — or choose an option that does not need it on this runtime.
A missing instrumentation package is instead a logged no-op, as described below; a missing context
manager leaves spans unnested, as described under Propagation.
import { TelemetryPlugin } from '@setu-ts/telemetry-plugin';
Usage
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { TelemetryPlugin } from '@setu-ts/telemetry-plugin';
import { CAPABILITIES, type ITelemetryService } from '@setu-ts/common';
const app = createApplication({
plugins: [
RuntimePlugin(),
TelemetryPlugin({
serviceName: 'orders-api',
exporter: 'otlp',
endpoint: 'http://localhost:4318/v1/traces',
spanProcessor: 'batch',
instrumentations: { http: true, ioredis: true },
}),
],
});
await app.start({ port: 3000 });
Options
| Option | Type | Default | Description |
|---|---|---|---|
serviceName | string | — | Required when an exporter is configured. |
serviceVersion | string | '1.0.0' | Reported to the exporter. |
exporter | SpanExporterKind | — | Absent means noop mode. |
endpoint | string | — | Required when exporter: 'otlp'. |
headers | Record<string, string> | — | Sent with OTLP requests. |
sampling | SamplingConfig | — | Sampling configuration. |
spanProcessor | 'simple' | 'batch' | 'simple' | Use 'batch' in production. |
middleware | boolean | true | Register the request-span middleware. |
instrumentations | InstrumentationsConfig | none | Per-instrumentation auto-instrumentation; see below. |
contextPropagation | boolean | true | Activate real spans for nested work. |
contextManagerFactory | () => Promise<{ enable(): unknown; disable(): unknown }> | — | Injectable async-local context manager loader: a factory returning a promise, not a manager instance. Its return type is structural on purpose, so the factory can resolve to an OTel context manager without importing a type from this package. |
redaction | RedactionPolicy | IRedactionService | — | Policy applied to retained query-parameter values when queryParameters: 'redact'. |
queryParameters | 'omit' | 'redact' | 'omit' | How http.url query parameters are handled; see Request URL handling. 'redact' without a redaction policy warns and fails closed to omission. |
diagnostics | TraceDiagnosticsOptions | — | Opt-in minimized completed-span observations; see Trace diagnostics (M98g). |
Auto-instrumentation
instrumentations takes a per-key object (http, fetch, ioredis, redis, amqplib,
kafkajs), each true or an InstrumentationConfig — not a bare string[]. It is Node-gated:
on an unsupported runtime, or when the @opentelemetry/instrumentation-* package is absent, the
instrumentation is a documented no-op, never a throw, so one codebase deploys everywhere.
redis traces node-redis (redis v5 and v6), including a client wrapped in fromNodeRedis from
@setu-ts/common: a SET sent through the adapter is recorded as a redis-SET span with
db.system.name redis. Enable ioredis, redis or both to match the clients the application
uses. node-redis 6 can also report OpenTelemetry metrics itself (OpenTelemetry.init); spans need
this arm. The instrumentation patches node-redis as Node loads it, and this plugin registers it when
the application starts, so redis must not be loaded before then: build the client through a
factory, fromNodeRedis(async () => (await import('redis')).createClient({ url })), as a
--redis-client node-redis scaffold does. A redis imported at module load records no spans.
Each plugin instance calls setTracerProvider on itself; there is no global singleton.
Outcomes are reported through the plugin’s logger (ctx.logger, read at call time): an enabled
instrumentation logs at debug, a failure logs at warn with kind and reason. A failure
remains a no-op rather than a throw — but it is no longer silent. The plugin declares the logger
capability as an optional dependency, so the kernel registers a plugin-provided logger (e.g.
LoggerPlugin) before it and the standard configuration reports every outcome; an app without a
logger plugin still boots, with nothing emitted.
Propagation
The request-span middleware reads and writes the W3C traceparent header, so traces join across
services without extra configuration.
For outbound calls made with @setu-ts/sdk, add its createTraceContextInterceptor(telemetry) to
the client’s request interceptors. It reads this plugin’s active span and carries the same trace to
the receiving service.
Span nesting is a separate mechanism with its own preconditions. In real OTel mode the plugin
registers an async-local context manager and withSpan runs its callback with the span active, so
nested work — including messaging publishes — becomes a child span. That applies only when all three
hold: the plugin is in real OTel mode (not noop or fallback), contextPropagation is not false,
and a context manager is active. Registration is reported through the logger and never throws.
Two outcomes count as active, and only one counts as failure:
- Registered. The plugin installed its own async-local manager. Nesting works.
- Adopted.
setGlobalContextManagerreported that the host already owns a manager, so the plugin keeps the host’s. Nesting still works — the existing manager carries the context — which is why this is reported as active rather than as a failure. - Failed. The optional
@opentelemetry/context-async-hookspackage could not be loaded, or the registration call itself threw. Only here are spans still recorded but arriving as unrelated siblings rather than a tree.
Pass an explicit parentContext where the relationship must hold regardless.
Pass root: true (M107) to start a span with NO parent — ignoring both parentContext and the
active span. Work that runs on behalf of something other than the surrounding request needs it: the
messaging outbox relays a row that carries no valid stored traceparent under a root span, so a
sweep dispatched from inside a request does not attribute that row to the request. The plugin
carries the option to OpenTelemetry’s own SpanOptions.root; an ITelemetryService implementation
that ignores it starts the span under the active span.
Request URL handling
Request spans omit query strings and fragments from http.url by default. Set
queryParameters: 'redact' together with redaction to retain query parameter names while applying
the supplied policy to each value (query.<name>). If redact mode has no policy, the plugin warns
at registration and continues to omit the query string.
Multiple backends
The plugin exports to one endpoint. To fan a single trace stream out to several vendors, point it at an OpenTelemetry Collector — see docs/telemetry-collector-fanout.md and the reference config in docker/otel-collector/.
Trace diagnostics (M98g)
TelemetryPlugin({ diagnostics }) opts into minimized completed-span observations for the local
diagnostics connector. With the built-in OTel provider, an internal span processor is appended AFTER
the exporter processor in the same provider constructor — the exporter path is unchanged, the
processor never exports and never throws into OTel — and every finished SAMPLED span whose exact raw
name appears in operations is reduced to its approved alias, W3C-validated identifiers,
relationships, kind, outcome, duration and age before anything is retained. Attributes, events,
resource labels, tracestate, baggage, exceptions and the raw span name never reach collector state.
The plugin always registers an ITraceDiagnosticsSource under CAPABILITIES.TRACE_DIAGNOSTICS:
without the option it answers disabled; with the option under a custom tracerProviderFactory or
without an exporter it answers unsupported with the fixed coverage reason. Sampling, context
activation and export behavior are unchanged. See
PUBLIC_API.md
and docs/diagnostics-protocol.md.
Exports
| Export | Kind |
|---|---|
telemetryMiddleware | function |
TelemetryPlugin | function |
NoopTelemetryService | class |
TELEMETRY_SPAN_KEY | const |
InstrumentationConfig | interface |
InstrumentationsConfig | interface |
ISpan | interface |
ITelemetryService | interface |
SamplingConfig | interface |
SpanOptions | interface |
TelemetryContext | interface |
TelemetryPluginOptions | interface |
TraceDiagnosticsOptions | interface |
TracerHost | interface |
InstrumentationKind | type |
SpanAttributeValue | type |
SpanExporterKind | type |
SpanKind | type |
SpanProcessorKind | type |
SpanStatus | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
Telemetry (@setu-ts/telemetry-plugin)
Provides OpenTelemetry distributed tracing. The TelemetryPlugin registers an ITelemetryService
under CAPABILITIES.TELEMETRY ('telemetry'), exposing manual span creation via withSpan plus a
request-span middleware at priority 30 (inside metrics at 20, outside auth at 300) that wraps every
inbound HTTP request in a server span with W3C traceparent/tracestate propagation.
The OpenTelemetry SDK is a heavy optional dependency: the plugin lazy-loads
@opentelemetry/sdk-trace-base and an exporter via dynamic imports of the bare package names,
failing with a clear error when the package is absent. A NoopTelemetryService is the default when
no exporter is configured, so the plugin always registers a usable service with zero npm deps in
that mode.
An injectable tracerProviderFactory option lets tests (and consumers with a pre-built provider)
bypass the lazy import entirely.
Options
| Option | Type | Required | Description |
|---|---|---|---|
serviceName | string | Yes (real mode) | Service name reported to the exporter |
serviceVersion | string | No | Service version (default: '1.0.0') |
exporter | 'otlp' | 'console' | No | Exporter kind; absent = noop mode |
endpoint | string | Yes (otlp) | OTLP HTTP endpoint URL |
headers | Record<string, string> | No | Optional OTLP HTTP headers |
sampling | { type: 'traceidratio'; ratio: number } | No | Sampling config (default ratio: 1.0) |
tracerProviderFactory | () => Promise<TracerHost> | No | Injectable factory to bypass lazy import |
middleware | boolean | No | Register request-span middleware (default: true) |
spanProcessor | 'simple' | 'batch' | No | Span processor ('simple' by default) |
instrumentations | InstrumentationsConfig | No | Auto-instrumentation config (runtime-gated no-op) |
contextPropagation | boolean | No | Activate real OTel spans (default: true) |
contextManagerFactory | () => Promise<{ enable(): unknown; disable(): unknown }> | No | Injectable context-manager factory — it returns a promise of a manager, not a manager. The return type is structural, so it can resolve to an OTel context manager with no import from this package |
diagnostics | TraceDiagnosticsOptions | No | Opt-in minimized completed-span observations for the local diagnostics connector (M98g, below) |
Trace diagnostics (M98g)
TraceDiagnosticsOptions is the opt-in observation policy for the local diagnostics connector’s
GET /v1/traces operation (documented in the Diagnostics Connector section). It requires the
LITERAL enabled: true, a serviceAlias, and an operations map of exact raw span name → approved
alias (at most 128; each alias 1–64 UTF-8 bytes, no control character, unique — approving an exact
alias IS authorizing its disclosure; serviceAlias is never derived from the OTel resource). Every
option is validated at TelemetryPlugin(...) construction with a fixed, value-free RangeError.
When present with the built-in OTel provider, the plugin appends an internal span processor AFTER
the exporter processor in the same provider constructor: the exporter path is byte-identical
(measured — the exporter still receives every span), the processor never exports, never throws into
OTel, and unapproved spans are counted and dropped before anything is read. The plugin ALWAYS
registers an ITraceDiagnosticsSource under CAPABILITIES.TRACE_DIAGNOSTICS (provides names it):
without the option it answers disabled, with the option under a custom tracerProviderFactory or
without an exporter it answers unsupported with the fixed coverage reason. Sampling, activation
and export behavior are unchanged. See
Diagnostics Connector for the served contract.
Span nesting and broker propagation
In real OTel mode, withSpan activates the span while its callback runs, so nested work inherits
the active parent. Implicit inheritance holds only when the plugin is in real OTel mode,
contextPropagation is not false, and the async-local context manager registered successfully;
noop mode, fallback mode, contextPropagation: false, and a failed registration — the optional
package not loading, or the registration call throwing — all leave spans flat. A host that already
owns a context manager is NOT a failure: the plugin adopts it and nesting still works. A failed
registration is logged, never thrown. Where the parent relationship must hold regardless of
activation, pass parentContext explicitly. When MessagingPlugin finds telemetry, it creates
publish <topic> producer spans and receive <topic> consumer spans, writes W3C traceparent on
the transport, and parents delivery from the header. Set tracing: false to opt out. All
first-party brokers expose the read transport headers through MessageMetadata.headers, using {}
when the channel is empty.
Auto-instrumentation
Milestone 24b adds the instrumentations option — a per-kind map of true | InstrumentationConfig
keys: http, fetch, ioredis, redis (M117: node-redis v5/v6 through
@opentelemetry/instrumentation-redis ^0.69.0, an optional peer; a command sent through
fromNodeRedis is traced as redis-<COMMAND>), amqplib, kafkajs. Each key enables one
auto-instrumentation. On non-Node runtimes (Deno, Bun, Cloudflare Workers) all instrumentations
degrade to a documented no-op — they never throw. When tracerProviderFactory returns a host
with a truthy otelProvider, the registry calls setTracerProvider + enable() on each loaded
instrumentation instance; when otelProvider is absent, the registry returns a no-op handle
immediately.
Each instrumentation uses the inject-or-lazy seam: when InstrumentationConfig.instrumentation
is set, the instance is used directly (inject path); otherwise the registry lazy-loads the OTel
package via a literal dynamic import of its bare package name (lazy path). Any loader failure is
caught and recorded as a failure outcome — the plugin never throws from instrumentation setup.
Every outcome is reported through the plugin’s logger (ctx.logger, read at call time): an enabled
instrumentation logs one line at debug, a failure one line at warn carrying kind and reason.
The plugin declares the logger capability in optionalDependencies, so the kernel orders a
plugin-provided logger (e.g. LoggerPlugin) before it — the standard configuration
(RuntimePlugin + LoggerPlugin + TelemetryPlugin) reports every outcome. A failure therefore
remains a no-op rather than a throw, but is no longer silent — without a logger registered, the
outcomes are still recorded on the registry handle and nothing is emitted.
Span Processor
The spanProcessor option selects between 'simple' (default) and 'batch' span processing. Both
are exported from the pinned @opentelemetry/sdk-trace-base@^2.9.0.
Registration
import { TelemetryPlugin } from '@setu-ts/telemetry-plugin';
// Noop mode (zero dependencies)
app.register(TelemetryPlugin({ serviceName: 'my-service' }));
// Real mode with console exporter
app.register(
TelemetryPlugin({
serviceName: 'my-service',
serviceVersion: '1.0.0',
exporter: 'console',
}),
);
// Real mode with OTLP exporter
app.register(
TelemetryPlugin({
serviceName: 'my-service',
exporter: 'otlp',
endpoint: config.get('OTLP_ENDPOINT'),
sampling: { type: 'traceidratio', ratio: 0.1 },
}),
);
Manual Spans
import { CAPABILITIES } from '@setu-ts/common';
import type { ITelemetryService } from '@setu-ts/common';
app.router.post('/orders', async (ctx) => {
const telemetry = ctx.services.get<ITelemetryService>(CAPABILITIES.TELEMETRY);
const order = await telemetry.withSpan('create-order', async (span) => {
span.setAttribute('customerId', ctx.request.body.customerId);
span.setAttribute('total', ctx.request.body.total);
const order = await createOrder(ctx.request.body);
span.setAttribute('orderId', order.id);
span.setStatus('ok');
return order;
});
return ctx.response.status(201).json(order);
});
Request-Span Middleware
The plugin registers telemetryMiddleware at priority 30 by default. It:
- Extracts
traceparent/tracestatefromctx.request.headers(W3C Trace Context) - Starts a server span named
<METHOD> <path> - Stores the span on
ctx.stateunderTELEMETRY_SPAN_KEY('telemetry-plugin:span') - Sets HTTP attributes (
http.method,http.route,http.status_code) - Injects
traceparentinto the response headers
Downstream handlers can read the active span via:
import { TELEMETRY_SPAN_KEY } from '@setu-ts/telemetry-plugin';
import type { ISpan } from '@setu-ts/common';
const activeSpan = ctx.state.get(TELEMETRY_SPAN_KEY) as ISpan | undefined;
Contract Types
The telemetry contract is framework-owned and exported from @setu-ts/common (zero dependencies —
importable without the OTel SDK installed). The telemetry-plugin translates these to OTel types at
its implementation seam.
| Export | Kind | Shape / description |
|---|---|---|
ITelemetryService | interface | withSpan<T>(name: string, fn: (span: ISpan) => Promise<T>, options?: SpanOptions): Promise<T> — the only manual span-creation API; ends the span exactly once, even if fn throws. Resolved under CAPABILITIES.TELEMETRY. Plus the OPTIONAL activeSpanContext?(): SpanContext | undefined (see below). |
ISpan | interface | setAttribute(key, value): this, setAttributes(attrs): this, setStatus(status): void, recordException(error): void, end(): void, spanContext(): SpanContext. |
SpanContext | interface | { readonly traceId: string; readonly spanId: string; readonly traceFlags: string } — all lowercase hex (32/16/2 chars). Returned by ISpan.spanContext(). |
SpanStatus | union | 'ok' | 'error' | 'unset' — argument to ISpan.setStatus. |
SpanKind | union | 'internal' | 'server' | 'client' | 'producer' | 'consumer' — SpanOptions.kind (default 'internal'). |
SpanAttributeValue | union | string | number | boolean | ReadonlyArray<string | number | boolean>. |
SpanOptions | interface | { readonly kind?: SpanKind; readonly attributes?: Readonly<Record<string, SpanAttributeValue>>; readonly parentContext?: TelemetryContext; readonly root?: boolean } — 3rd arg to withSpan. Pass parentContext to parent a span explicitly; implicit linking depends on context activation — see note below. root: true (M107) starts a parentless span, ignoring both parentContext and the active span; an ITelemetryService implementation that ignores it starts the span under the ACTIVE span. |
TelemetryContext | interface | Opaque parent-context handle carrying the extracted W3C fields (_opaque, optional traceId/spanId/traceFlags/tracestate). Consumers must not inspect it beyond passing it back via SpanOptions.parentContext. |
TraceparentSource | interface | Structural traceId?/spanId?/traceFlags? input accepted by contextToTraceparent; both TelemetryContext and SpanContext satisfy it. |
TELEMETRY_CONTEXT_OPAQUE | unique symbol | Brand for TelemetryContext._opaque (Symbol.for('he.telemetry.context')); prevents structural mixups. |
Implicit parent/child linking is conditional. Since M75 the plugin DOES register an OTel
ContextManager— theAsyncLocalStorageContextManagerfrom the optional@opentelemetry/context-async-hooks— so in real OTel mode awithSpannested inside another auto-parents. That holds only while a context manager is active: the plugin either registers its own or adopts one the host already owns, and both nest. It does NOT hold in noop or fallback mode, undercontextPropagation: false, or when the optional package cannot be loaded and registration therefore fails — there, spans are recorded but unparented.Pass
parentContext(or the extracted context) onSpanOptionswherever the relationship must hold regardless of activation. The request-span middleware always passes the incomingtraceparentas the parent explicitly, so cross-process propagation (incoming header → server span) works out of the box in every mode.
Joining log records to spans
ITelemetryService.activeSpanContext?(): SpanContext | undefined reports the identifiers of the
span that is active RIGHT NOW, so a signal emitted OUTSIDE a withSpan call — a log record, above
all — can name the trace it belongs to. withSpan hands its span only to its own callback, so
without this a consumer that is merely running inside a span cannot ask which one.
TracerHost carries the matching optional activeSpanContext?(); it is where the read is actually
possible, because only the host can see the OTel context. Both are OPTIONAL, so every existing
implementor stays source-compatible, and the ABSENCE is meaningful rather than substituted: a
service or host that cannot see an active span omits the member, and a consumer then enriches
nothing rather than emitting empty identifiers that would join a record to no trace. TracerHost
declares it only alongside activate — with no registered context manager nothing is ever active,
so a host that cannot activate could only ever answer undefined. NoopTelemetryService omits it.
@setu-ts/logger-plugin is the consumer that makes this more than a getter: with a telemetry
capability registered it enriches every record with trace_id and span_id. See the logging
section for the field names and the absent-telemetry behaviour.