@stateloom/telemetry
Analytics hooks for state change tracking.
Install
pnpm add @stateloom/core @stateloom/store @stateloom/telemetrynpm install @stateloom/core @stateloom/store @stateloom/telemetryyarn add @stateloom/core @stateloom/store @stateloom/telemetrySize: ~0.5 KB gzipped (+ core)
Overview
The telemetry middleware observes store lifecycle events — state changes, initialization, and subscriptions — and forwards metadata to user-defined callbacks. Telemetry is purely observational: it never blocks or modifies state updates. All callbacks are wrapped in try/catch so telemetry errors can never crash the application.
Quick Start
import { createStore } from '@stateloom/store';
import { telemetry } from '@stateloom/telemetry';
const t = telemetry<{ count: number }>({
onStateChange: (meta) => {
console.log(`Changed in ${meta.durationMs}ms (change #${meta.changeCount})`);
},
onError: (ctx) => console.error('Telemetry error:', ctx.error),
});
const store = createStore((set) => ({ count: 0 }), { middleware: [t] });
store.setState({ count: 1 });
// logs: "Changed in 0.05ms (change #1)"Guide
Creating the Middleware
Call telemetry() with an options object containing callback functions. All callbacks are optional.
import { telemetry } from '@stateloom/telemetry';
// Minimal — no callbacks, just a passthrough
const t = telemetry();
// With state change tracking
const t = telemetry({
onStateChange: (meta) => analytics.track('state_change', meta),
});Attaching to a Store
Pass the telemetry instance in the middleware array:
import { createStore } from '@stateloom/store';
const store = createStore((set) => ({ count: 0 }), { middleware: [t] });Tracking State Changes
The onStateChange callback fires after each state update with rich metadata:
const t = telemetry<{ count: number }>({
onStateChange: (meta) => {
console.log('Previous:', meta.prevState);
console.log('Next:', meta.nextState);
console.log('Partial applied:', meta.partial);
console.log('Duration (ms):', meta.durationMs);
console.log('Change #:', meta.changeCount);
console.log('Timestamp:', meta.timestamp);
},
});The callback fires after next(partial), so meta.nextState reflects the committed state.
Filtering State Changes
Use the filter option to control which state changes trigger onStateChange:
const t = telemetry<{ count: number; name: string }>({
filter: (prev, next) => prev.count !== next.count,
onStateChange: (meta) => {
// Only fires when `count` changes, not `name`
},
});The changeCount increments for every state change regardless of whether the filter passes.
Tracking Initialization
The onInit callback fires once when the store initializes:
const t = telemetry<{ count: number }>({
onInit: (initialState, meta) => {
console.log('Store initialized with:', initialState);
console.log('At:', meta.timestamp);
},
});Tracking Subscriptions
The onSubscribe callback fires when a new listener subscribes to the store:
const t = telemetry<{ count: number }>({
onSubscribe: (meta) => {
console.log('Total subscriptions:', meta.totalSubscriptions);
},
});TIP
totalSubscriptions is monotonically increasing because the Middleware<T> interface has no onUnsubscribe hook. It tracks how many subscriptions have been created, not the current active count.
Error Handling
All telemetry callbacks are wrapped in try/catch. Errors are forwarded to onError:
const t = telemetry<{ count: number }>({
onStateChange: () => {
throw new Error('oops');
},
onError: (ctx) => {
console.error(ctx.error); // Error: oops
console.log(ctx.source); // 'onStateChange'
console.log(ctx.state); // current state (if available)
},
});If onError itself throws, the error is silently swallowed. Telemetry must never crash the application.
Enable/Disable at Runtime
Toggle telemetry on and off without removing the middleware:
const t = telemetry<{ count: number }>({
onStateChange: (meta) => analytics.track(meta),
});
// Later, in response to user preference
t.setEnabled(false); // pause telemetry
t.isEnabled(); // false
t.setEnabled(true); // resume telemetryWhen disabled:
next()is always called — state updates are never blocked- No telemetry callbacks fire
changeCountandtotalSubscriptionscontinue to increment
Advanced: Combining with Other Middleware
Telemetry works alongside other middleware in the pipeline:
import { history } from '@stateloom/history';
import { telemetry } from '@stateloom/telemetry';
const h = history();
const t = telemetry({
onStateChange: (meta) => console.log(meta.durationMs),
});
const store = createStore((set) => ({ count: 0 }), { middleware: [t, h] });API Reference
telemetry<T>(options?: TelemetryOptions<T>): TelemetryMiddleware<T>
Create a telemetry middleware instance.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
options | TelemetryOptions<T> | undefined | Callback configuration | undefined |
Returns: TelemetryMiddleware<T> — a middleware instance with enable/disable controls.
import { telemetry } from '@stateloom/telemetry';
const t = telemetry<{ count: number }>({
onStateChange: (meta) => console.log(meta),
onError: (ctx) => console.error(ctx),
});Key behaviors:
- All callbacks are optional — omitted hooks are simply not invoked
- The timer uses
performance.now()for sub-millisecond precision withDate.now()fallback - The timer is resolved once at factory time, not per-call
- Structurally compatible with
Middleware<T>from@stateloom/store
TelemetryOptions<T>
Configuration options for the telemetry middleware.
| Property | Type | Description |
|---|---|---|
onStateChange | (metadata: StateChangeMetadata<T>) => void | Called after each state change (unless filtered/disabled) |
onInit | (initialState: T, metadata: InitMetadata) => void | Called when the store initializes |
onSubscribe | (metadata: SubscriptionMetadata) => void | Called when a new subscription is added |
onError | (context: ErrorContext<T>) => void | Called when any callback throws |
filter | (prevState: T, nextState: T, partial: Partial<T>) => boolean | Controls which changes trigger onStateChange |
StateChangeMetadata<T>
Metadata provided to the onStateChange callback.
| Property | Type | Description |
|---|---|---|
name | string | Always "telemetry" |
prevState | T | State before the update |
nextState | T | State after the update |
partial | Partial<T> | The partial state that was applied |
timestamp | string | ISO 8601 timestamp |
durationMs | number | Duration of the state transition in milliseconds |
changeCount | number | Monotonically increasing change counter |
InitMetadata
Metadata provided to the onInit callback.
| Property | Type | Description |
|---|---|---|
name | string | Always "telemetry" |
timestamp | string | ISO 8601 timestamp |
SubscriptionMetadata
Metadata provided to the onSubscribe callback.
| Property | Type | Description |
|---|---|---|
name | string | Always "telemetry" |
totalSubscriptions | number | Total subscriptions observed since creation |
timestamp | string | ISO 8601 timestamp |
ErrorContext<T>
Error context provided to the onError callback.
| Property | Type | Description |
|---|---|---|
error | unknown | The error that was caught |
source | 'onStateChange' | 'onInit' | 'onSubscribe' | 'filter' | Which callback threw |
state | T | undefined | Current state at error time (if available) |
TelemetryMiddleware<T>
The telemetry middleware instance.
Extends the standard Middleware<T> hooks with enable/disable controls.
| Method | Description |
|---|---|
setEnabled(enabled: boolean) | Enable or disable telemetry at runtime |
isEnabled(): boolean | Check whether telemetry is currently enabled |
Patterns
Analytics Integration
const t = telemetry<AppState>({
onStateChange: (meta) => {
analytics.track('state_change', {
duration: meta.durationMs,
changeCount: meta.changeCount,
timestamp: meta.timestamp,
});
},
onError: (ctx) => {
errorReporter.captureException(ctx.error, {
extra: { source: ctx.source },
});
},
});Conditional Telemetry
const t = telemetry<AppState>({
onStateChange: (meta) => console.log(meta),
});
// Disable in production
if (import.meta.env.PROD) {
t.setEnabled(false);
}Performance Monitoring
const SLOW_THRESHOLD = 16; // 1 frame at 60fps
const t = telemetry<AppState>({
filter: (prev, next, partial) => true,
onStateChange: (meta) => {
if (meta.durationMs > SLOW_THRESHOLD) {
console.warn(`Slow state update: ${meta.durationMs}ms`, meta.partial);
}
},
});How It Works
The telemetry middleware uses a closure-based factory pattern:
Timer resolution: At factory time,
createTimer()checks forperformance.now()availability and falls back toDate.now(). The timer function is resolved once and reused.onSethook: Before callingnext(partial), the middleware records the start time and capturesprevState. Afternext()completes, it incrementschangeCount, applies thefilter, and firesonStateChangewith metadata.Error safety: Every user callback invocation is wrapped in try/catch. Caught errors are forwarded to
onError. IfonErrorthrows, the error is silently swallowed.Enable/disable: A simple boolean flag. When disabled,
next()is always called (state is never blocked), but callbacks are skipped. Counters still increment to maintain accurate totals.Destroy: Resets
changeCountandtotalSubscriptionsto zero, allowing the middleware instance to be reused with a new store.
TypeScript
import { telemetry } from '@stateloom/telemetry';
import type {
TelemetryMiddleware,
TelemetryOptions,
StateChangeMetadata,
InitMetadata,
SubscriptionMetadata,
ErrorContext,
} from '@stateloom/telemetry';
interface AppState {
count: number;
}
// Type parameter inferred from options
const t = telemetry<AppState>({
onStateChange: (meta) => {
// meta is StateChangeMetadata<AppState>
const count: number = meta.nextState.count;
},
filter: (prev, next) => {
// prev and next are AppState
return prev.count !== next.count;
},
});
// Explicit type annotation
const t2: TelemetryMiddleware<AppState> = telemetry<AppState>();When to Use
| Scenario | Use Telemetry? |
|---|---|
| Development logging | Yes — track state changes during development, disable in production |
| Analytics integration | Yes — forward state metadata to analytics services |
| Performance monitoring | Yes — use durationMs to detect slow updates |
| Error tracking | Yes — onError catches all callback errors with context |
| Production debugging | Yes with caution — enable only when needed to minimize overhead |
| Real-time monitoring | Yes — combine with filter to track specific changes |