Measure in your own analytics

Forward flag exposures to PostHog, Amplitude, Segment, or GA4 with the onEvaluation hook, and run experiments where your data already lives.

Every flag check in both SDKs can fire a callback: onEvaluation in JavaScript, on_evaluation in Python. Wire that callback into the analytics tool you already use and you have experiment measurement, without sending Switchbox any of your data. Your tool already has your conversion events and already manages identity. The only thing it is missing is "which variant did this user see", and the hook supplies exactly that.

One boundary up front, because it is the whole shape of this recipe: Switchbox delivers the variant and guarantees the assignment is stable. It does not collect exposures, join them to outcomes, or compute a winner. Results live in your analytics tool, not in the Switchbox dashboard. If you want the flag vendor to run the statistics for you, that is a different kind of product.

The hook

Register the callback when you create the client. It fires after every evaluation (enabled and getValue alike) with the flag key, the resolved value, and the user context you passed in:

import { Switchbox } from 'switchbox-js';
const client = await Switchbox.create({
sdkKey: 'your-sdk-key',
onEvaluation: (flagKey, value, user) => {
// forward to your analytics
},
});
from switchbox import Switchbox
def on_evaluation(flag_key, value, user):
... # forward to your analytics
client = Switchbox(sdk_key="your-sdk-key", on_evaluation=on_evaluation)

The hook is fire-and-forget: an exception inside your handler never breaks the flag check; it is reported through the client's onError (on_error in Python) callback instead. The hook also carries zero evaluation logic, so wiring it up changes nothing about how flags resolve.

Dedupe before you send

A flag is often checked far more times than a user is meaningfully exposed to it: once per render, once per request, a hundred times in a loop. Forwarding every call would flood your analytics (and most tools bill per event). The recipes below therefore wrap the handler in a small guard that sends one exposure per user, flag, and value for the lifetime of the page (or process), which is a good default window:

const seen = new Set();
const SEEN_MAX = 5000;
function oncePerExposure(handler) {
return (flagKey, value, user) => {
const key = `${user?.user_id ?? 'anon'}:${flagKey}:${JSON.stringify(value)}`;
if (seen.has(key)) return;
if (seen.size >= SEEN_MAX) seen.clear(); // bound the guard's memory
seen.add(key);
handler(flagKey, value, user);
};
}

If the value changes mid-session (say a rollout is dialed up and the user crosses into it), the key changes, so the new exposure is sent too. Drop the guard if your tool dedupes for you, or tighten it if you want at most one event per flag regardless of value.

Two practical notes. The set grows with distinct user/flag/value combinations, so it must be bounded: the cap will rarely trip in a browser tab, and when it does, clearing costs at most one repeated exposure per key, which any analytics tool tolerates. And building the key stringifies the value on every check; negligible for boolean and string flags, but if you evaluate large json flags on a hot path, key on the user and flag only.

PostHog

PostHog has a semi-official event for this, $feature_flag_called, which makes exposures show up in its feature flag and experiment tooling:

import posthog from 'posthog-js';
const client = await Switchbox.create({
sdkKey: 'your-sdk-key',
onEvaluation: oncePerExposure((flagKey, value) => {
posthog.capture('$feature_flag_called', {
$feature_flag: flagKey,
$feature_flag_response: value,
});
}),
});

Identity alignment: posthog.capture attaches PostHog's own distinct_id, the same one your conversion events carry. That join is what makes the experiment readable, so let the analytics library supply identity rather than passing something from the flag context.

Amplitude

import * as amplitude from '@amplitude/analytics-browser';
const client = await Switchbox.create({
sdkKey: 'your-sdk-key',
onEvaluation: oncePerExposure((flagKey, value) => {
amplitude.track('flag_exposure', {
flag_key: flagKey,
variant: String(value),
});
}),
});

The event rides Amplitude's current user identity. Build funnels or segment any metric by flag_key + variant to compare arms.

Segment

Segment fans one event out to your whole stack, so this single handler covers every downstream destination:

onEvaluation: oncePerExposure((flagKey, value) => {
analytics.track('Flag Exposed', {
flagKey,
variant: String(value),
});
}),

Identity comes from analytics.identify, exactly like the rest of your Segment events.

Google Analytics 4

onEvaluation: oncePerExposure((flagKey, value) => {
gtag('event', 'flag_exposure', {
flag_key: flagKey,
variant: String(value),
});
}),

Register flag_key and variant as custom dimensions in GA4 so you can slice reports by them.

Server-side in Python

The same pattern works on the backend. Here with PostHog's Python library, and a per-process guard in place of the page-lifetime one:

from posthog import Posthog
from switchbox import Switchbox
posthog = Posthog("phc_your_key", host="https://eu.i.posthog.com")
_seen: set[tuple] = set()
_SEEN_MAX = 10_000
def on_evaluation(flag_key, value, user):
distinct_id = (user or {}).get("user_id")
if distinct_id is None:
return # nothing to join on
key = (distinct_id, flag_key, repr(value))
if key in _seen:
return
if len(_seen) >= _SEEN_MAX:
_seen.clear() # bound memory; costs at most one duplicate per key
_seen.add(key)
posthog.capture(
distinct_id=distinct_id,
event="$feature_flag_called",
properties={"$feature_flag": flag_key, "$feature_flag_response": value},
)
client = Switchbox(sdk_key="your-server-sdk-key", on_evaluation=on_evaluation)

Server-side you usually do want the flag context's user_id as the identity, because it is the same ID your backend events already use. The rule is the same either way: the exposure must carry the identity your conversion events carry.

Note the bound matters more here than in the browser: a long-running server sees real user cardinality, so an uncapped set is a slow memory leak. The capped clear trades an occasional duplicate exposure for flat memory. (Unlike the JS example, this handler drops evaluations with no user_id instead of bucketing them as anonymous — server-side there is no analytics library supplying an identity, so an exposure without one has nothing to join on.)

The one rule: keep assignment stable

Variant assignment is a deterministic hash of user_id and the flag key, identical across both SDKs (pinned by shared parity vectors). A given user gets the same variant on every poll, on every service, in every language. That stability is the one experiment-correctness guarantee Switchbox owns, and it holds as long as you do not move the dials mid-test:

  • Freeze the rollout percentage while a test runs. Dialing 50% up to 60% does not reshuffle anyone, but it admits new users whose exposure window differs; dialing down silently moves users from treatment back to control and corrupts both arms.
  • Do not rename the flag key or edit targeting rules mid-test. The key is the hash seed, so a rename rebuckets every user.
  • Conclude with the dial: treatment won means set the rollout to 100%, control won means 0%. Both are safe because the experiment is over.

If you follow the A/B test recipe's pattern of bundling a variant label into the flag value, the exposure event can carry the label directly instead of the raw value.

What Switchbox deliberately does not do

  • No auto-send. The hook hands you the evaluation; you build the event with your identity. That keeps identity alignment, consent, and data residency in your hands, and keeps end-user data out of ours.
  • No results page. Significance, funnels, and dashboards live in your tool.
  • Separately from this hook, the SDKs report anonymous usage telemetry (per-flag evaluation counts, no identity or context) that powers the flag usage panel in the dashboard. Disable it with telemetry: false if you want no reporting at all.

Where to next