Telemetry Design
Low-level design for @stateloom/telemetry — the non-blocking observation middleware. Covers the observation architecture, callback isolation, timer resolution, filter mechanism, and enable/disable lifecycle.
Overview
The telemetry middleware provides observational hooks for state changes, subscriptions, and initialization without ever blocking or modifying the state pipeline. All user-provided callbacks are wrapped in try/catch to guarantee that telemetry errors never affect application state. The middleware exposes runtime enable/disable controls, so telemetry can be toggled without rebuilding the store.
Architecture
The telemetry() factory returns a single object that implements both the Middleware<T> hooks (init, onSet, onSubscribe, onDestroy) and the telemetry-specific controls (setEnabled, isEnabled). All state is held in closures — no class instances, no prototype chain.
Timer Resolution
The createTimer() function selects the best available timer at construction time:
function createTimer(): () => number {
const g = globalThis as Record<string, unknown>;
const perf = g['performance'] as { now?: () => number } | undefined;
if (perf !== undefined && typeof perf.now === 'function') {
const perfNow = perf.now.bind(perf);
return () => perfNow();
}
return () => Date.now();
}The timer is resolved once when telemetry() is called, not per state change. performance.now() provides sub-millisecond precision in browsers and Node.js. The bind(perf) call prevents this-binding issues. In environments without the Performance API (e.g., some edge runtimes), the fallback is Date.now().
Implementation Details
onSet Hook — Non-Blocking Observation
The onSet hook always calls next(partial) first, ensuring the state update is never blocked:
Key behaviors:
next(partial)is called unconditionally — telemetry never blocks state updateschangeCountincrements even when disabled or filtered — it tracks total mutationsprevStateis captured beforenext(),nextStateafter — sodurationMsmeasures the full middleware chain below telemetryendTimeis captured after the filter check, keeping the filter's cost out ofdurationMs
Error Isolation
Every user callback is wrapped in try/catch. Errors are forwarded to onError. If onError itself throws, the error is swallowed:
function safeOnError(context: ErrorContext<T>): void {
if (onError === undefined) return;
try {
onError(context);
} catch {
// onError itself threw — swallow to protect the app
}
}This creates a two-layer isolation boundary:
- Callback layer: try/catch around
onStateChange,onInit,onSubscribe, andfilter - Error handler layer: try/catch around
onErroritself
The ErrorContext<T> carries the original error, the source callback name ('onStateChange' | 'onInit' | 'onSubscribe' | 'filter'), and the current state (when available).
Filter Mechanism
The optional filter predicate controls which state changes trigger onStateChange:
if (filter !== undefined) {
let shouldFire: boolean;
try {
shouldFire = filter(prevState, nextState, partial);
} catch (error: unknown) {
safeOnError({ error, source: 'filter', state: nextState });
return;
}
if (!shouldFire) return;
}The filter receives (prevState, nextState, partial) and returns true to fire onStateChange or false to skip. Filter errors are routed to onError and treated as "skip" — the state change is not reported but the state update itself is not affected.
Subscription Tracking
The onSubscribe hook counts total subscriptions and fires an optional callback:
onSubscribe(_api: MiddlewareAPI<T>, listener: Listener<T>): Listener<T> {
totalSubscriptions += 1;
if (enabled && onSubscribeCb !== undefined) {
try {
onSubscribeCb({
name: 'telemetry',
totalSubscriptions,
timestamp: new Date().toISOString(),
});
} catch (error: unknown) {
safeOnError({ error, source: 'onSubscribe', state: undefined });
}
}
return listener;
}The listener is returned unchanged — telemetry does not wrap or modify listener behavior. totalSubscriptions is monotonically increasing because Middleware<T> has no onUnsubscribe hook.
Enable/Disable Toggle
setEnabled(false) pauses all telemetry callbacks without removing the middleware. State updates still flow through next() and counters still increment — only the user callbacks are skipped.
setEnabled(value: boolean): void {
enabled = value;
}This allows production deployments to ship with telemetry middleware installed but disabled, turning it on via a feature flag or debugging tool without rebuilding the store.
Destroy and Reset
The onDestroy hook resets counters:
onDestroy(): void {
changeCount = 0;
totalSubscriptions = 0;
}This ensures that if a store is recreated with the same telemetry instance, counters start fresh.
Design Decisions
Why Observation-Only (Never Block State)
Telemetry middleware always calls next(partial) before any observation logic. This makes it safe to add telemetry to any store without risking behavioral changes. If telemetry blocked or transformed state, it would violate the principle that observation should not change what is observed.
Why Structural Typing Instead of Importing from Store
The types file redeclares SetFn<T>, Listener<T>, and MiddlewareAPI<T> as structural matches rather than importing from @stateloom/store. This keeps @stateloom/telemetry independent of the paradigm layer — it only peer-depends on @stateloom/core, consistent with the architecture rule that middleware packages are paradigm-agnostic.
Why Two-Layer Error Isolation
A single try/catch around callbacks would still leave the onError callback unprotected. If onError threw, it would propagate into the middleware chain and potentially crash the store. The two-layer approach ensures that no telemetry code path can throw, regardless of what user callbacks do.
Why changeCount Increments When Disabled
Counters track total mutations across the store's lifetime, not just observed mutations. This makes changeCount useful as a sequence number for correlation across enable/disable cycles. If counters reset on disable, a re-enable would produce confusing metadata (e.g., changeCount: 1 after thousands of mutations).
Why Filter Errors Are Treated as "Skip"
If the filter throws, the safest behavior is to skip the onStateChange callback rather than fire it with potentially inconsistent state. The error is still reported via onError, so it's not silently lost.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Timer overhead | Resolved once at construction; performance.now() is < 1 microsecond | O(1) per state change |
| Disabled fast path | Early return after next() when !enabled | O(1) boolean check |
| Filter short-circuit | Filter evaluated before building metadata object | Avoids metadata allocation on skip |
| Error isolation | try/catch per callback — no overhead when no error | Zero cost on happy path (V8 optimizes) |
| Closure-based state | No class, no prototype lookup, no allocation per hook call | Single closure scope per factory call |
| Timestamp formatting | new Date().toISOString() only called when callback fires | Lazy — not computed on filtered/disabled changes |
Cross-References
- Middleware Overview — where telemetry fits in the middleware taxonomy
- Architecture Overview — layer structure and dependency graph
- Core Design — signal internals that the store depends on
- Store Design — middleware pipeline that telemetry hooks into
- API Reference:
@stateloom/telemetry— consumer-facing documentation