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