Skip to content

@stateloom/telemetry ​

Analytics hooks for state change tracking.

Install ​

bash
pnpm add @stateloom/core @stateloom/store @stateloom/telemetry
bash
npm install @stateloom/core @stateloom/store @stateloom/telemetry
bash
yarn add @stateloom/core @stateloom/store @stateloom/telemetry

Size: ~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 ​

typescript
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.

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 telemetry

When disabled:

  • next() is always called — state updates are never blocked
  • No telemetry callbacks fire
  • changeCount and totalSubscriptions continue to increment

Advanced: Combining with Other Middleware ​

Telemetry works alongside other middleware in the pipeline:

typescript
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:

ParameterTypeDescriptionDefault
optionsTelemetryOptions<T> | undefinedCallback configurationundefined

Returns: TelemetryMiddleware<T> — a middleware instance with enable/disable controls.

typescript
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 with Date.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.

PropertyTypeDescription
onStateChange(metadata: StateChangeMetadata<T>) => voidCalled after each state change (unless filtered/disabled)
onInit(initialState: T, metadata: InitMetadata) => voidCalled when the store initializes
onSubscribe(metadata: SubscriptionMetadata) => voidCalled when a new subscription is added
onError(context: ErrorContext<T>) => voidCalled when any callback throws
filter(prevState: T, nextState: T, partial: Partial<T>) => booleanControls which changes trigger onStateChange

StateChangeMetadata<T> ​

Metadata provided to the onStateChange callback.

PropertyTypeDescription
namestringAlways "telemetry"
prevStateTState before the update
nextStateTState after the update
partialPartial<T>The partial state that was applied
timestampstringISO 8601 timestamp
durationMsnumberDuration of the state transition in milliseconds
changeCountnumberMonotonically increasing change counter

InitMetadata ​

Metadata provided to the onInit callback.

PropertyTypeDescription
namestringAlways "telemetry"
timestampstringISO 8601 timestamp

SubscriptionMetadata ​

Metadata provided to the onSubscribe callback.

PropertyTypeDescription
namestringAlways "telemetry"
totalSubscriptionsnumberTotal subscriptions observed since creation
timestampstringISO 8601 timestamp

ErrorContext<T> ​

Error context provided to the onError callback.

PropertyTypeDescription
errorunknownThe error that was caught
source'onStateChange' | 'onInit' | 'onSubscribe' | 'filter'Which callback threw
stateT | undefinedCurrent state at error time (if available)

TelemetryMiddleware<T> ​

The telemetry middleware instance.

Extends the standard Middleware<T> hooks with enable/disable controls.

MethodDescription
setEnabled(enabled: boolean)Enable or disable telemetry at runtime
isEnabled(): booleanCheck whether telemetry is currently enabled

Patterns ​

Analytics Integration ​

typescript
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 ​

typescript
const t = telemetry<AppState>({
  onStateChange: (meta) => console.log(meta),
});

// Disable in production
if (import.meta.env.PROD) {
  t.setEnabled(false);
}

Performance Monitoring ​

typescript
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:

  1. Timer resolution: At factory time, createTimer() checks for performance.now() availability and falls back to Date.now(). The timer function is resolved once and reused.

  2. onSet hook: Before calling next(partial), the middleware records the start time and captures prevState. After next() completes, it increments changeCount, applies the filter, and fires onStateChange with metadata.

  3. Error safety: Every user callback invocation is wrapped in try/catch. Caught errors are forwarded to onError. If onError throws, the error is silently swallowed.

  4. 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.

  5. Destroy: Resets changeCount and totalSubscriptions to zero, allowing the middleware instance to be reused with a new store.

TypeScript ​

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 ​

ScenarioUse Telemetry?
Development loggingYes — track state changes during development, disable in production
Analytics integrationYes — forward state metadata to analytics services
Performance monitoringYes — use durationMs to detect slow updates
Error trackingYes — onError catches all callback errors with context
Production debuggingYes with caution — enable only when needed to minimize overhead
Real-time monitoringYes — combine with filter to track specific changes