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

```js
import { Switchbox } from 'switchbox-js';

const client = await Switchbox.create({
  sdkKey: 'your-sdk-key',
  onEvaluation: (flagKey, value, user) => {
    // forward to your analytics
  },
});
```

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

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

```js
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

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

```js
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

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

```python
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](/docs/recipes/ai-prompt-ab-test) 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

- [AI: A/B test two prompts](/docs/recipes/ai-prompt-ab-test): a complete
  experiment built on a rollout, exposure, and outcome events.
- [Percentage rollouts](/docs/concepts/rollouts): how deterministic bucketing
  works.
- [JavaScript SDK](/docs/sdk/javascript) · [Python SDK](/docs/sdk/python): the
  full `onEvaluation` and telemetry reference.
