Send flag exposures to your analytics tool
A percentage rollout or a targeting rule decides which variant each user gets. To learn whether variant B converts better than A, your analytics tool needs two things: which variant each user saw (an exposure) and what the user did afterwards (an outcome, such as a purchase). Mocco's OpenFeature providers hand both to a function you write, and you pass them on to the tool you already use. The tool joins them by user and computes the result. Mocco itself never receives them.
This guide assumes flags are already set up in your code, as in the quickstart for servers or Feature flags in browsers and apps for web pages and apps.
1. Add onExposure to the provider
All three providers take the same three options:
import { MoccoWebProvider } from '@mocco/openfeature-web';
import { OpenFeature } from '@openfeature/web-sdk';
await OpenFeature.setProviderAndWait(
new MoccoWebProvider({
publishableKey: 'mk_pub_…',
onExposure: event => sendToAnalytics(event),
exposureAttributes: ['plan'],
}),
);| Option | What it does |
|---|---|
onExposure | Your function. It receives exposures and outcome events (below). |
exposureAttributes | The evaluation-context attributes to copy into each event, such as plan or country. Nothing else from the context is copied. Default: none. |
exposureCacheSize | How many (flag, variant, user) combinations the provider remembers to avoid repeats. Default: 10,000. |
MoccoProvider (@mocco/openfeature-server) and MoccoReactNativeProvider (@mocco/openfeature-react-native) take the same options.
2. What your function receives
An exposure looks like this:
{
type: 'exposure',
flagKey: 'new-checkout',
variant: 'b',
reason: 'TARGETING_MATCH', // or 'SPLIT'
targetingKey: 'user_8f3a…', // the context's targetingKey
environment: 'production', // servers only, for now
rulesetVersion: 42, // servers only, for now
timestamp: '2026-11-09T09:00:00.000Z',
attributes: { plan: 'pro' }, // only what exposureAttributes lists
}When does a read count?
Only when a rule or a percentage rollout chose the variant. A flag that serves the same value to everyone, a disabled flag, your code's default, an error or an unknown flag never counts: nobody was assigned to anything.
Only when your code reads the flag. In browsers and apps the provider fetches every flag at once; that fetch alone counts nothing. The exposure happens when the page or screen asks for the flag, because that is when the user can see the variant.
Once per flag, variant and user. Later reads are skipped while the provider remembers the combination: for the life of the server process, the page, or the app session. Your analytics tool may dedupe as well; that's fine.
Saved answers count too. When a page or app starts from the answers it saved last time (OpenFeature reports the reason
CACHED), a read still counts if a rule chose that variant, and the event carries the original reason.
environment and rulesetVersion come from what the provider knows. A server provider reads them from the ruleset it evaluates. A browser or app receives only answers, which don't carry them yet, so they are undefined there; add the environment yourself (you build each environment with its own key) if your reports need it.
An outcome comes from OpenFeature's tracking call:
OpenFeature.getClient().track('checkout_completed', { value: 49.0, currency: 'USD' });Your function then receives:
{
type: 'track',
name: 'checkout_completed',
targetingKey: 'user_8f3a…',
value: 49,
details: { currency: 'USD' },
timestamp: '2026-11-09T09:01:12.000Z',
attributes: { plan: 'pro' },
}If your app already sends conversions to the analytics tool directly, you don't need track. It is there so the same function can carry both.
Your function can't break flag evaluation: if it throws, or returns a promise that fails, the error is dropped and the flag value is served as usual.
3. Two rules before you send anything
The targeting key must be the analytics user id. Your analytics tool joins exposures to outcomes by user. If the flag's targetingKey is user_8f3a but the tool knows the user as 42, the two never meet and the experiment shows nothing. Use the same id for both: identify the user in the analytics tool with the value you pass as targetingKey. For signed-out visitors, use the tool's anonymous id (for example PostHog's get_distinct_id()) as the targetingKey; a visitor who signs in and gets a new id may then land in a different variant.
Send only what you mean to send. The full evaluation context can hold email addresses, IP addresses or plans you haven't announced, so the provider never copies it: only the attributes you list in exposureAttributes are passed on. Prefer a pseudonymous targetingKey (an internal user id, not an email address), and send variant names rather than flag values, which can hold text you'd rather keep private.
4. Recipes
The analytics recipes are complete onExposure functions. Datadog and Sentry show flags next to sessions and errors, so they want every evaluation and use an OpenFeature hook instead. Names like posthog stand for the client you already set up for that tool.
PostHog
PostHog treats its $feature_flag_called event as the exposure for experiments:
onExposure: event => {
if (event.type === 'exposure') {
posthog.capture('$feature_flag_called', {
$feature_flag: event.flagKey,
$feature_flag_response: event.variant,
});
} else {
posthog.capture(event.name, { value: event.value, ...event.details });
}
},In the browser, call posthog.identify(user.id) with the same id you pass as targetingKey. On a server, a single process serves many users, so pass the user explicitly with posthog-node:
onExposure: event => {
if (event.type === 'exposure' && event.targetingKey !== undefined) {
posthog.capture({
distinctId: event.targetingKey,
event: '$feature_flag_called',
properties: { $feature_flag: event.flagKey, $feature_flag_response: event.variant },
});
}
},Amplitude
Amplitude Experiment counts an $exposure event with the flag and variant, and adds the variant to the user's properties so later events can be split by it:
onExposure: event => {
if (event.type === 'exposure') {
amplitude.track('$exposure', { flag_key: event.flagKey, variant: event.variant });
} else {
amplitude.track(event.name, { value: event.value, ...event.details });
}
},Set the user with amplitude.setUserId(user.id), the same id as targetingKey.
Mixpanel
Mixpanel's experiment reports read the $experiment_started event:
onExposure: event => {
if (event.type === 'exposure') {
mixpanel.track('$experiment_started', {
'Experiment name': event.flagKey,
'Variant name': event.variant,
});
} else {
mixpanel.track(event.name, { value: event.value, ...event.details });
}
},Call mixpanel.identify(user.id) with the targetingKey value.
Google Analytics 4
GA4 has a recommended event for a user entering an experiment variant, experience_impression, with the variant in exp_variant_string:
onExposure: event => {
if (event.type === 'exposure') {
gtag('event', 'experience_impression', {
exp_variant_string: `mocco-${event.flagKey}-${event.variant}`,
});
} else {
gtag('event', event.name, { value: event.value, ...event.details });
}
},Set user_id in your gtag('config', …) call to the targetingKey value. GA4 can't run the statistics itself; compare the variants in an exploration, or export to BigQuery.
Segment
Segment's spec names the exposure Experiment Viewed; destinations such as Amplitude, Mixpanel and warehouses map it for you:
onExposure: event => {
if (event.type === 'exposure') {
analytics.track('Experiment Viewed', {
experiment_id: event.flagKey,
variation_id: event.variant,
});
} else {
analytics.track(event.name, { value: event.value, ...event.details });
}
},Call analytics.identify(user.id) with the targetingKey value.
OpenTelemetry
OpenTelemetry's semantic conventions describe a feature_flag.evaluation event. Emit it as a log record:
import { logs } from '@opentelemetry/api-logs';
const logger = logs.getLogger('mocco-flags');
onExposure: event => {
if (event.type === 'exposure') {
logger.emit({
eventName: 'feature_flag.evaluation',
attributes: {
'feature_flag.key': event.flagKey,
'feature_flag.result.variant': event.variant,
'feature_flag.result.reason': event.reason.toLowerCase(),
'feature_flag.provider.name': 'Mocco',
...(event.targetingKey !== undefined && { 'feature_flag.context.id': event.targetingKey }),
...(event.rulesetVersion !== undefined && { 'feature_flag.version': String(event.rulesetVersion) }),
},
});
}
},This records first exposures. To record every evaluation instead (for traces or metrics), add an OpenFeature hook such as the OpenTelemetry hooks in OpenFeature's js-sdk-contrib repository; they work with any provider, Mocco's included.
Datadog RUM
Datadog RUM attaches flag values to the current view and to its errors, so you can filter sessions and errors by variant. Each view needs the flags read during it, not just the first time a user met them, so use an OpenFeature hook, which runs on every evaluation, instead of onExposure:
import { OpenFeature } from '@openfeature/web-sdk';
OpenFeature.addHooks({
after: (hookContext, details) => {
if (details.variant !== undefined) {
datadogRum.addFeatureFlagEvaluation(hookContext.flagKey, details.variant);
}
},
});Turn on feature flag collection when you initialize RUM, as Datadog's setup guide for your SDK version describes, and set the user with datadogRum.setUser({ id: user.id }), the same id as targetingKey. The hook sends variant names, not values.
Sentry
Sentry attaches recent flag evaluations to each error, so you can search issues by flag. Like Datadog it needs every evaluation, not first exposures, and it ships its own OpenFeature hook for that; add it next to Mocco's provider instead of using onExposure:
import * as Sentry from '@sentry/browser';
import { OpenFeature } from '@openfeature/web-sdk';
Sentry.init({ dsn: '…', integrations: [Sentry.openFeatureIntegration()] });
OpenFeature.addHooks(new Sentry.OpenFeatureIntegrationHook());For a server or a React Native app, follow Sentry's feature flag guide for that SDK. Sentry records boolean flag values only, so a flag with string or number variants doesn't appear there.
Common questions
Does Mocco see these events? No. Mocco keeps only per-flag counts of evaluations, which it uses to point out stale flags. Exposures and outcomes go only where your function sends them.
I only want exposures for some flags. Check event.flagKey in your function, for example against a list of flags that are running experiments.
Can I have exposures without a percentage rollout? Yes. Any rule that picks a variant for some users, such as "plan is pro", counts. A flag that serves one value to everyone doesn't, because there is nothing to compare.