Skip to Content
FeatureWhat did the engine just answer?

Observing

@evanion/feature 0.1.0 and its patch releases up to 0.1.1, the version on npm. Values on this page were produced by running the source at e53b2d8, a later commit than the 0.1.0 tag, and are not re-executed.

The observe option of createFeatures installs a callback that receives one event for each resolve, isEnabled, plan, toggle and snapshot call, carrying the record that call produced.

How to Install an Observer

When an @evanion/feature feature is off for a customer, the decision says whether the kill switch, a rollout, a parent or a time window turned it off, and the caller usually discards that decision. To keep these records, you can install an observer.

You install the observer as the observe option when calling createFeatures. The store calls it once for each call in the table below, and the event carries the payload the table names.

import { } from '@evanion/feature'; import type { } from '@evanion/feature'; const : [] = []; const = ( [ { : 'new-checkout', : true }, { : 'gift-cards', : true }, ], { : () => { .(); }, : '2026-11-01', }, ); const = new ('2026-11-01T00:00:00Z'); .({ : 'cust-0042', }); .; // -> 1 [0]; // -> { type: 'resolve', at: new Date('2026-11-01T00:00:00Z'), subject: 'cust-0042', version: '2026-11-01', decisions: { 'new-checkout': { key: 'new-checkout', enabled: true, reason: 'default-on' }, 'gift-cards': { key: 'gift-cards', enabled: true, reason: 'default-on' } } }

The Four Event Types

To track how @evanion/feature behaves, you can listen for events that carry a standard envelope: the at instant, a subject read off the context’s targetingKey by default, and the version option of createFeatures when you set one. The payload depends on the call:

Event TypeCallPayload
resolveresolve(context), snapshot(context)A record of decisions for every configured feature.
is-enabledisEnabled(key, context)The specific decision for that one key.
planplan(context)The build-time partition of settled vs. deferred features.
toggletoggle(key, enabled, context?)The result of the change, a refused unknown-feature included.

These four event types cover five different calls. Because snapshot resolves every feature and reports a resolve event, a consumer filtering by type cannot distinguish between the two.

A snapshot event carries the set’s decisions record and nothing else from the DecisionSet the caller received. It has no origin, and its at is a Date where the set states now as an ISO 8601 string. The event’s version is the version option of createFeatures, and the set’s version is the version of the installed document, so the two can differ; a store built from a versioned document with no version option reports no version on the event at all. An auditor matching snapshot events to the sets a server shipped joins on subject, the instant and the decisions.

Note that variantOf and valueOf report nothing, and isEnabled for a key the store was not built with reports nothing either. While isEnabled resolves the whole store to answer one question, the caller receives a boolean and the event carries the one decision that boolean was read from:

import { } from '@evanion/feature'; import type { } from '@evanion/feature'; const : [] = []; const = ( [ { : 'new-checkout', : true }, { : 'gift-cards', : true }, ], { : () => { .(); }, }, ); const = new ('2026-11-01T00:00:00Z'); .('gift-cards', { : 'cust-0042', }); .; // -> 1 [0]; // -> { type: 'is-enabled', at: new Date('2026-11-01T00:00:00Z'), subject: 'cust-0042', key: 'gift-cards', decision: { key: 'gift-cards', enabled: true, reason: 'default-on' } }

An observer never awaits and never changes an outcome

An @evanion/feature observer is called and the engine immediately moves on. If the observer returns a promise, the engine attaches a rejection handler but does not await the result. This ensures that a slow audit transport (e.g., a network call) never slows down the user’s request.

Measured at forty features on an Apple M1 Pro under Node 24.20.0, the bare call ran at 23 microseconds, a synchronous no-op observer at 41, and an async no-op observer at 44. The deep freeze described below accounts for that difference, and the emit and the thenable check stay within run-to-run noise of the bare call. So the number anyone should care about is what their own hook body does.

Read-Only Payloads

When an observer is installed, the library deeply freezes the decision records before emitting them. This prevents an observer from accidentally modifying a decision and changing the outcome for the user. The caller receives the same frozen object, so resolve, plan and toggle on an observed store answer a value that throws a TypeError on a write.

The freeze happens only under an installed observer, so the package behaves differently under two configurations. At forty features the freeze adds 18 microseconds to a 23 microsecond resolve, and freezing on every call would charge that to every user of the package whether or not they observe anything. A store nobody observes hands its record to nobody else, so the freeze protects nothing there. An application that edits a decision installs no observer, or copies the record first.

When an observer throws

If an @evanion/feature observer throws an error or returns a rejected promise, the engine catches it and forwards it to onObserveError. The original call to resolve or isEnabled still returns the correct value to the user; the failure of the observer does not crash the application.

import { } from '@evanion/feature'; const : string[] = []; const = ([{ : 'new-checkout', : true }], { : () => { throw new ('audit transport down'); }, : () => { .(()); }, }); .('new-checkout'); // -> true ; // -> ['Error: audit transport down']

With no onObserveError, the engine writes one console.warn per distinct failure, at most twenty per store, and none when NODE_ENV is production. An onObserveError that throws is reported the same way, and the engine does not call it again for that event.

Records in flight are lost at exit

Because @evanion/feature never awaits an observer, a promise it never awaited and a buffer the application has not drained are both gone when the process exits. Drain the buffer before the process exits.

If your transport is asynchronous (e.g., a remote logging service), the observer pushes events into a local buffer and returns, and the application drains that buffer at a concurrency it owns.

import { } from '@evanion/feature'; import type { } from '@evanion/feature'; const : [] = []; const : [][] = []; const = { : (: []) => .() }; const = ([{ : 'new-checkout', : true }], { : () => { .(); }, }); .({ : 'cust-0042' }); .('new-checkout', { : 'cust-0107' }); // A server runs this on exit: process.on('SIGTERM', drain) const = () => .(.(0)); (); .; // -> 0 [0]?.(() => .); // -> ['resolve', 'is-enabled']

A server drains on SIGTERM. A browser drains on visibilitychange and writes with navigator.sendBeacon, because an unload cancels a fetch the page had already started. A native client writes to a local queue that persists across a process restart. A phone’s operating system suspends a process without warning, so an application reporting from a phone drains on the background transition.

Features declares no drain(). Every real transport already owns a flush, and a store-level one would have to agree with it.

correlateBy and what reaches an event

To understand how events are tracked in @evanion/feature, you must first understand what an event carries. An @evanion/feature event carries no EvaluationContext; instead, it carries the instant the call settled on, the payload the event type names, and a subject identifier copied out as a string or number. To determine which field to use for this identifier, @evanion/feature uses correlateBy, which names the context field the identifier is read from and defaults to targetingKey.

import { } from '@evanion/feature'; import type { } from '@evanion/feature'; const : [] = []; const = ([{ : 'new-checkout', : true }], { : () => { .(); }, : 'analyticsId', }); const = new ('2026-11-01T00:00:00Z'); .({ : 'cust-0042', : 'anon-7f3c', }); [0]?.; // -> 'anon-7f3c'

The default discloses nothing on its own. The engine evaluates in your own process and sends nothing upstream, and the subject on an event is the value your application put on its own EvaluationContext a moment earlier. A library that returns a caller its own data discloses nothing, and an application that forwards the event off the box has made that decision itself. An application bucketing on a raw customer ID points correlateBy at a field carrying a pseudonym.

Exposure tracking is not what this seam carries

The observe option of @evanion/feature, the seam this page covers, reports a decision (observation). An exposure is a customer seeing the feature, and the seam does not report one.

The Observation Gap

Observation happens when resolve() is called. In a server-side render, this occurs once per request. If your store has 40 features, the one resolve event carries 40 decisions. However, the user might only actually see 3 of those features on the page.

If you use the observer to track experiment “exposures,” your data will be heavily skewed by features that were resolved but never actually rendered.

The Correct Pattern: The Client-Side Ping

Server Components cannot track when they are actually seen by a user. So the application records exposure where it renders, in a small client component, off the decision it already holds. The component calls useFeature('checkout-cta') and records decision.variant, decision.assignment?.source, decision.assignment?.bucket and decision.rule in an effect:

import { } from '@evanion/feature'; import { } from '@evanion/react-feature'; import { } from 'react'; import { } from 'react-dom/server'; const = ([ { : 'checkout-cta', : true, : [ { : 'control', : 50 }, { : 'blue', : 50, : { : 'Get it' } }, ], }, ]); const { , } = (); const : object[] = []; const = { : (: object) => { .(); }, }; // components/cta.tsx, a client module ('use client') function ({ }: { : string }) { const = ('checkout-cta'); (() => { .({ : 'checkout-cta', : ., : .?., : .?., : ., : , }); }, [, ]); return < ="button">{.?. ?? 'Buy'}</>; } // app/page.tsx, a Server Component function ({ }: { : string }) { const = .({ : }); // The provider re-resolves the set to check it, so it carries the field the // snapshot bucketed on. Without it the provider publishes the shipped // variant and reports it as one it cannot reproduce. return ( < ={{ : }} ={} > < ={} /> </> ); } const = (< ="cust-0199" />); ; // -> '<button type="button">Get it</button>' ; // -> []

The server render runs no effect, so it records nothing. This ensures that an exposure is only recorded if the component is actually delivered to the browser and mounted, providing the only accurate denominator for your A/B test results.

useFeature returns the whole decision, which carries every field that record needs. useVariant returns variant and value alone, so a component that only renders the assigned variant calls that one, and a component that writes an exposure record calls useFeature.

What the seam does cover

An @evanion/feature observer covers decisions, as decisions. An operator reads the no-rule-matched breakdown and the dependency-off cause off a resolve event. An auditor reads a toggle event to see what was turned off and which dependants went with it, and a version on every event shows which configuration each replica decided under during a rollout.

API reference lists every option createFeatures takes, observe, onObserveError, correlateBy and version among them.

Last updated on