@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

FeatureOptional 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

OptionTypeDefaultDescription
serviceNamestring—Required when an exporter is configured.
serviceVersionstring'1.0.0'Reported to the exporter.
exporterSpanExporterKind—Absent means noop mode.
endpointstring—Required when exporter: 'otlp'.
headersRecord<string, string>—Sent with OTLP requests.
samplingSamplingConfig—Sampling configuration.
spanProcessor'simple' | 'batch''simple'Use 'batch' in production.
middlewarebooleantrueRegister the request-span middleware.
instrumentationsInstrumentationsConfignonePer-instrumentation auto-instrumentation; see below.
contextPropagationbooleantrueActivate 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.
redactionRedactionPolicy | 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.
diagnosticsTraceDiagnosticsOptions—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. setGlobalContextManager reported 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-hooks package 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

ExportKind
telemetryMiddlewarefunction
TelemetryPluginfunction
NoopTelemetryServiceclass
TELEMETRY_SPAN_KEYconst
InstrumentationConfiginterface
InstrumentationsConfiginterface
ISpaninterface
ITelemetryServiceinterface
SamplingConfiginterface
SpanOptionsinterface
TelemetryContextinterface
TelemetryPluginOptionsinterface
TraceDiagnosticsOptionsinterface
TracerHostinterface
InstrumentationKindtype
SpanAttributeValuetype
SpanExporterKindtype
SpanKindtype
SpanProcessorKindtype
SpanStatustype

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

OptionTypeRequiredDescription
serviceNamestringYes (real mode)Service name reported to the exporter
serviceVersionstringNoService version (default: '1.0.0')
exporter'otlp' | 'console'NoExporter kind; absent = noop mode
endpointstringYes (otlp)OTLP HTTP endpoint URL
headersRecord<string, string>NoOptional OTLP HTTP headers
sampling{ type: 'traceidratio'; ratio: number }NoSampling config (default ratio: 1.0)
tracerProviderFactory() => Promise<TracerHost>NoInjectable factory to bypass lazy import
middlewarebooleanNoRegister request-span middleware (default: true)
spanProcessor'simple' | 'batch'NoSpan processor ('simple' by default)
instrumentationsInstrumentationsConfigNoAuto-instrumentation config (runtime-gated no-op)
contextPropagationbooleanNoActivate real OTel spans (default: true)
contextManagerFactory() => Promise<{ enable(): unknown; disable(): unknown }>NoInjectable 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
diagnosticsTraceDiagnosticsOptionsNoOpt-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:

  1. Extracts traceparent/tracestate from ctx.request.headers (W3C Trace Context)
  2. Starts a server span named <METHOD> <path>
  3. Stores the span on ctx.state under TELEMETRY_SPAN_KEY ('telemetry-plugin:span')
  4. Sets HTTP attributes (http.method, http.route, http.status_code)
  5. Injects traceparent into 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.

ExportKindShape / description
ITelemetryServiceinterfacewithSpan<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).
ISpaninterfacesetAttribute(key, value): this, setAttributes(attrs): this, setStatus(status): void, recordException(error): void, end(): void, spanContext(): SpanContext.
SpanContextinterface{ readonly traceId: string; readonly spanId: string; readonly traceFlags: string } — all lowercase hex (32/16/2 chars). Returned by ISpan.spanContext().
SpanStatusunion'ok' | 'error' | 'unset' — argument to ISpan.setStatus.
SpanKindunion'internal' | 'server' | 'client' | 'producer' | 'consumer' — SpanOptions.kind (default 'internal').
SpanAttributeValueunionstring | number | boolean | ReadonlyArray<string | number | boolean>.
SpanOptionsinterface{ 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.
TelemetryContextinterfaceOpaque 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.
TraceparentSourceinterfaceStructural traceId?/spanId?/traceFlags? input accepted by contextToTraceparent; both TelemetryContext and SpanContext satisfy it.
TELEMETRY_CONTEXT_OPAQUEunique symbolBrand 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 — the AsyncLocalStorageContextManager from the optional @opentelemetry/context-async-hooks — so in real OTel mode a withSpan nested 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, under contextPropagation: 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) on SpanOptions wherever the relationship must hold regardless of activation. The request-span middleware always passes the incoming traceparent as 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.


Agent card · TypeDoc API reference