OpenTelemetry
OpenTelemetry tracing for commiq stores — commands as spans, opt-in cross-store propagation, abandoned-command detection, and cardinality/PII controls.
OpenTelemetry
@naikidev/commiq-otel instruments commiq stores with OpenTelemetry tracing. Each command becomes a span, and events emitted during that command are recorded as span events, giving end-to-end visibility in any OTel-compatible backend.
Installation
pnpm add @naikidev/commiq-otel @opentelemetry/apinpm install @naikidev/commiq-otel @opentelemetry/apiyarn add @naikidev/commiq-otel @opentelemetry/apibun add @naikidev/commiq-otel @opentelemetry/api@opentelemetry/api and @naikidev/commiq are both peer dependencies. Core is a peer rather than a direct dependency because a nested second copy of core meant otel instrumented a different store instance than the one the app used, and spans silently stopped appearing.
Basic usage
import { createCommandDef, createStore, sealStore } from "@naikidev/commiq";
import { instrumentStore } from "@naikidev/commiq-otel";
const increment = createCommandDef("increment");
const store = createStore({ count: 0 });
store.addCommandHandler(increment, (ctx) => {
ctx.setState({ count: ctx.state.count + 1 });
});
const instrumentation = instrumentStore(store, { storeName: "counter" });
const sealed = sealStore(store);
sealed.queue(increment);instrumentStore(store, options)
function instrumentStore(
store: Streamable,
options: InstrumentOptions,
): StoreInstrumentation;store needs only openStream/closeStream, so a StoreImpl or a SealedStore both work.
StoreInstrumentation is both callable and a Disposable:
instrumentation(); // uninstrument
instrumentation.destroy(); // identicalBoth are idempotent and interchangeable. The callable form keeps v1 call sites working; destroy() lets otel join the same generic teardown loop as every other commiq plugin:
import type { Disposable } from "@naikidev/commiq";
const plugins: Disposable[] = [instrumentation, devtools, effects];
for (const plugin of plugins) plugin.destroy();Disposal removes the stream listener and ends any in-flight spans as abandoned.
InstrumentOptions
| Option | Type | Default | Description |
|---|---|---|---|
storeName | string | required | Non-empty. Set as commiq.store on every span |
tracerName | string | "commiq" | Tracer name |
tracerVersion | string | undefined | Tracer version |
registry | TraceRegistry | private per call | Shared registry enabling cross-store propagation |
maxCommandDurationMs | number | 60000 | Commands still pending after this window are ended as abandoned. 0 or non-finite disables the sweep |
maxPendingCommands | number | 1024 | Hard cap on concurrently tracked command spans; the oldest are ended as abandoned on overflow |
recordCorrelationIds | boolean | false | Record correlation ids as span attributes instead of a span event |
sanitizeError | (error: unknown) => string | error type name | Maps an error to the text placed in span status and exception events |
An empty or non-string storeName now throws. It was previously accepted, producing spans attributed to "" that were impossible to correlate with a store.
Cross-store propagation
Each instrumentStore call keeps its own state. Two independent stores never share span parents by default, so interleaved work — SSR, multi-tenant Node, concurrent requests — cannot fabricate causal edges.
This is a behaviour change. In v1 cross-store parenting happened implicitly through module-global state keyed by correlation id alone, never by store. An unknown causedBy fell back to whatever command happened to be running in another store — a fabricated causal edge that, in a multi-tenant Node process, crossed traces between users. Unknown parents now inherit the real ambient OTel context instead.
To trace a causal chain that genuinely crosses stores, pass one shared registry to every participant:
import { createTraceRegistry, instrumentStore } from "@naikidev/commiq-otel";
const registry = createTraceRegistry({ maxEntries: 512 });
instrumentStore(orders, { storeName: "orders", registry });
instrumentStore(payments, { storeName: "payments", registry });A command queued from an event handler in another store is then parented to the span of the command that emitted the event — even if that command has already finished.
Scope the registry to the trust boundary. One registry per request or per tenant on a server; a module-level registry is fine in a browser, where there is only one user.
TraceRegistry
| Member | Description |
|---|---|
link(correlationId, link) | Record a TraceLink for a correlation id |
resolve(correlationId) | Look up a TraceLink, or undefined |
size() | Current entry count |
clear() | Drop every entry |
TraceRegistryOptions | Default | Description |
|---|---|---|
maxEntries | 512 | Cap, with oldest-first eviction |
The registry stores immutable SpanContext values — never live Span objects. In v1 every non-command event registered an alias holding a strongly-referenced ended Span, and nothing removed it: roughly 72,000 retained entries an hour at 20 events a second, and far worse now that stateChanged fires per setState.
Tracing model
Each command creates a span living from commandStarted to commandHandled, commandHandlingError or commandInterrupted:
commiq.command:increment
├─ span event: commiq.correlation
├─ span event: stateChanged
└─ span event: itemAddedEvents emitted during a command are recorded as span events on that command's span. Events with no resolvable live parent create short standalone commiq.event:<name> spans.
Core publishes stateChanged on every ctx.setState(), not once per command. A handler calling setState three times adds three span events. Span names stay low-cardinality, but span event volume is higher than in v1 — worth knowing if your backend bills per span event.
What gets recorded
| Store event | Span behaviour |
|---|---|
commandStarted | Starts commiq.command:<name>, parented via causedBy or the active OTel context |
commandHandled | Ends the span with OK |
commandHandlingError | Ends the span with ERROR plus a sanitized exception event |
commandInterrupted | Ends the span with OK, message "interrupted", plus the interrupted attributes |
invalidCommand | Short ERROR span — no handler registered |
stateChanged, custom events | Span event on the causing command span, or a standalone commiq.event:<name> span when the cause is unknown |
eventHandlingError | Short ERROR span commiq.event_handler:<event name>; the command span stays OK |
unhandledError | Short ERROR span commiq.error:<source> |
stateReset, <command>:handled | Ignored |
eventHandlingError and unhandledError come from core's error channel and are new in v2. A throwing event handler no longer suppresses commandHandled, so the command span still closes OK while the failure gets its own error span — the command genuinely succeeded; a downstream reaction did not.
Span attributes
Command spans (commiq.command:<name>):
| Attribute | Description |
|---|---|
commiq.store | Store name |
commiq.command.name | Command name |
commiq.command.interrupted | true when the command was interrupted |
commiq.command.interrupted_phase | "queued" or "running", only when interrupted |
commiq.command.abandoned | true when the span was swept — see below |
commiq.command.abandoned_reason | "timeout", "overflow" or "disposed" |
Standalone event spans (commiq.event:<name>):
| Attribute | Description |
|---|---|
commiq.store | Store name |
commiq.event.name | Event name |
Error spans: commiq.event_handler:<name> carries commiq.store and commiq.event.name; commiq.error:<source> carries commiq.store and commiq.error.source.
Correlation ids are not in this table by default — see Cardinality and PII.
Abandoned commands
A command that never settles — a hung await, navigation mid-flight — would leave an unended span, and unended spans are never exported. The whole trace silently vanished in exactly the case you most need it.
Such spans are now ended with SpanStatusCode.ERROR plus:
commiq.command.abandoned—truecommiq.command.abandoned_reason—"timeout","overflow"or"disposed"
| Reason | Cause |
|---|---|
timeout | Still pending after maxCommandDurationMs |
overflow | Evicted because maxPendingCommands was exceeded |
disposed | Still in flight when the instrumentation was disposed |
The sweep timer only exists while commands are in flight and is cleared on disposal. Set maxCommandDurationMs: 0 to disable it — accepting that a hung command then loses its trace.
An abandoned span is a real signal in your backend: an ERROR span with commiq.command.abandoned=true means a command in your app never finished. Alert on it.
Cardinality and PII
Span names are low-cardinality: commiq.command:<name>, commiq.event:<name>.
Correlation ids
Correlation ids are high-cardinality by nature — one distinct value per span — which drives cost on backends that index attributes. By default they are recorded on a commiq.correlation span event instead:
| Span event attribute | On |
|---|---|
commiq.command.correlation_id, commiq.command.caused_by | Command spans |
commiq.event.correlation_id, commiq.event.caused_by | Event spans and span events |
caused_by is omitted when the cause is null.
Set recordCorrelationIds: true to record them as span attributes instead, if your backend does not index or derive metrics from attributes. That was v1's behaviour.
Error text
sanitizeError defaults to the error type only — "TypeError" — not its message. Raw error messages routinely contain user input, and exporting them to a third-party vendor exports that input with them. If you relied on messages reaching your traces, opt in explicitly.
import { defaultSanitizeError, instrumentStore } from "@naikidev/commiq-otel";
instrumentStore(store, {
storeName: "users",
sanitizeError: (error) => (error instanceof Error ? error.message : defaultSanitizeError(error)),
});A sanitizeError that throws, or returns a non-string, falls back to the error type — it cannot break instrumentation.
No command payload and no state is ever attached to spans, in either version.
Cleanup
const instrumentation = instrumentStore(store, { storeName: "counter" });
instrumentation.destroy();Disposal removes the stream listener and ends every in-flight span with abandoned_reason: "disposed", so partial traces still reach the backend.
In v1 disposal called end() once per registered alias, so a still-running command's span was truncated and exported as ending before its own children. Lifecycle now lives in a tracker holding only in-flight owned spans, so each span is ended exactly once.
Full example
import { NodeSDK } from "@opentelemetry/sdk-node";
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-node";
import { createCommandDef, createStore, sealStore } from "@naikidev/commiq";
import { createTraceRegistry, instrumentStore } from "@naikidev/commiq-otel";
const sdk = new NodeSDK({ traceExporter: new ConsoleSpanExporter() });
sdk.start();
const increment = createCommandDef("increment");
const store = createStore({ count: 0 });
store.addCommandHandler(increment, (ctx) => {
ctx.setState({ count: ctx.state.count + 1 });
});
const registry = createTraceRegistry();
const instrumentation = instrumentStore(store, {
storeName: "counter",
tracerName: "my-app",
registry,
});
const sealed = sealStore(store);
await sealed.queue(increment);
instrumentation.destroy();
await sdk.shutdown();queue() returns a CommandHandle that never rejects, so awaiting it is safe without a try/catch — it resolves with a status of "handled", "failed", "interrupted", "invalid" or "discarded".
Exported types
InstrumentOptions, StoreInstrumentation, TraceRegistry, TraceRegistryOptions, TraceLink, ErrorSanitizer.
import type { ErrorSanitizer, InstrumentOptions } from "@naikidev/commiq-otel";
const sanitize: ErrorSanitizer = (error) => (error instanceof RangeError ? "RangeError" : "Error");
const otelOptions: InstrumentOptions = { storeName: "cart", sanitizeError: sanitize };Testing locally
| Backend | Best for | Setup |
|---|---|---|
| Jaeger | Local debugging | docker run -p 16686:16686 jaegertracing/all-in-one |
| Aspire Dashboard | .NET ecosystem | See browser telemetry guide |
| Zipkin | Lightweight tracing | docker run -p 9411:9411 openzipkin/zipkin |
| Grafana Tempo | Production-grade | Requires the Grafana stack |
ConsoleSpanExporter is enough for quick debugging without any backend.