Skip to content

Latest commit

 

History

History
426 lines (309 loc) · 13.5 KB

File metadata and controls

426 lines (309 loc) · 13.5 KB

@stateloom/telemetry

Analytics hooks for state change tracking.

Install

::: code-group

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

:::

Size: ~0.5 KB gzipped (+ core)

Overview

graph LR
    TF["telemetry(options?)"] --> TM["TelemetryMiddleware"]
    TM --> OSC["onStateChange"]
    TM --> OI["onInit"]
    TM --> OS["onSubscribe"]
    TM --> OE["onError"]
    TM --> SE["setEnabled()"]
    TM --> IE["isEnabled()"]
    TM --> MW["middleware pipeline"]

    style TM fill:#e8daef,stroke:#7d3c98
    style MW fill:#d5f5e3,stroke:#1e8449
Loading

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

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

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:

  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

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