Skip to content

Analytics & Experimentation Integration

Featureflip server SDKs can hand every flag evaluation to your own code as it happens, in-process, with no network egress owned by Featureflip. You decide where the data goes: an analytics pipeline, an experimentation tool, or a log line for debugging.

You register one or more inspectors when you create the client. Each inspector is a plain function called synchronously on every boolVariation / stringVariation / numberVariation / jsonVariation / variationDetail call.

Available in every server SDK — JavaScript (@featureflip/js), Node (@featureflip/node), Python, C#, Go, Java, PHP, and Ruby — and in every client SDK: Browser (@featureflip/browser), React (@featureflip/react), Swift, Android, and Flutter. Client SDKs receive pre-evaluated results from the Evaluation API rather than running a local evaluator, so their inspectors fire from a different point in the code — see Client SDKs below.

Each inspector receives one object. The field names are the same in every SDK, adapted to each language’s naming convention (flagKey in JavaScript, flag_key in Python and Ruby, FlagKey in C# and Go):

interface EvaluationEvent {
flagKey: string; // the flag that was evaluated
context: EvaluationContext; // the full context you passed (a read-only copy)
value: unknown; // the value your code received (default applied)
variationKey?: string; // the served variation ("on", "control", ...)
reason: string; // why this value was served (see below)
ruleId?: string; // set only when a targeting rule matched
prerequisiteKey?: string; // set only when a prerequisite was not met
timestamp: string; // ISO-8601
}

reason uses each SDK’s own native representation, because every SDK’s evaluator is the source of truth for its own runtime. The set of outcomes is identical everywhere; only the spelling changes:

SDKreason isExample
JavaScript, Node, Rubya string"RuleMatch", "PrerequisiteFailed"
Python, Javathe SDK’s EvaluationReason enumEvaluationReason.RULE_MATCH
C#the SDK’s EvaluationReason enum, PascalCaseEvaluationReason.RuleMatch
Gothe SDK’s EvaluationReason constantfeatureflip.ReasonRuleMatch
PHPa string, SCREAMING_SNAKE_CASE'RULE_MATCH', 'PREREQUISITE_FAILED'
Browser, React, Swift, Android, Flutter (client SDKs)a string, the engine’s own kebab-case, forwarded verbatim"fallthrough", "rule-match:rule-abc-123", "prerequisite-failed"

Compare against your SDK’s own reason type rather than against a hardcoded string from another language’s example.

flag-not-found is the one reason a client SDK synthesizes rather than forwards from the server — it’s the client-side spelling of the same FlagNotFound / FLAG_NOT_FOUND outcome the server SDKs’ local evaluators already report (see the reason table above). A client SDK synthesizes it locally (never sent by the server) whenever the flag key is absent from the client’s snapshot — unknown key, client not yet initialized, or the flag isn’t marked clientSideVisible. See Client SDKs for the full behavior.

import { FeatureflipClient } from '@featureflip/node';
const client = await FeatureflipClient.create({
sdkKey: process.env.FEATUREFLIP_SDK_KEY!,
inspectors: [
(event) => {
console.log(`[flag] ${event.flagKey} -> ${event.variationKey} (${event.reason})`);
},
],
});
from featureflip import Config, FeatureflipClient
def log_exposure(event):
print(f"[flag] {event.flag_key} -> {event.variation_key} ({event.reason})")
client = FeatureflipClient(
sdk_key=os.environ["FEATUREFLIP_SDK_KEY"],
config=Config(inspectors=[log_exposure]),
)
var options = new FeatureFlagOptions
{
Inspectors =
{
e => Console.WriteLine($"[flag] {e.FlagKey} -> {e.VariationKey} ({e.Reason})"),
},
};
var client = FeatureflipClient.Get(
Environment.GetEnvironmentVariable("FEATUREFLIP_SDK_KEY"),
options);
client, err := featureflip.Get(
os.Getenv("FEATUREFLIP_SDK_KEY"),
featureflip.WithInspectors(func(e featureflip.EvaluationEvent) {
log.Printf("[flag] %s -> %s (%s)", e.FlagKey, e.VariationKey, e.Reason)
}),
)
FeatureflipClient client = FeatureflipClient.builder(System.getenv("FEATUREFLIP_SDK_KEY"))
.inspectors(List.of(event ->
System.out.printf("[flag] %s -> %s (%s)%n",
event.getFlagKey(), event.getVariationKey(), event.getReason())))
.build();

The PHP SDK is transport-agnostic: cache, httpClient and requestFactory are required, and you supply your own PSR-16 / PSR-18 / PSR-17 implementations.

$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
$config = new Config(
cache: $yourPsr16Cache,
httpClient: new \GuzzleHttp\Client(['timeout' => 30]),
requestFactory: $httpFactory,
streamFactory: $httpFactory,
inspectors: [
function (EvaluationEvent $event): void {
error_log("[flag] {$event->flagKey} -> {$event->variationKey} ({$event->reason})");
},
],
);
$client = FeatureflipClient::get(getenv('FEATUREFLIP_SDK_KEY'), $config);
config = Featureflip::Config.new(
inspectors: [
->(event) { puts "[flag] #{event.flag_key} -> #{event.variation_key} (#{event.reason})" }
]
)
client = Featureflip::Client.get(ENV["FEATUREFLIP_SDK_KEY"], config: config)

Inspectors are honored on the first client created for a given SDK key. Pass a list; every callable in it is called on each evaluation, in registration order. Entries that aren’t callable are ignored rather than causing an error at evaluation time.

Browser, React, Swift, Android, and Flutter also support inspectors, with the identical EvaluationEvent shape described above. Register one the same way you would on a server SDK:

import { FeatureflipClient } from '@featureflip/browser';
const client = FeatureflipClient.get({
clientKey: 'your-client-sdk-key',
inspectors: [
(event) => {
console.log(`[flag] ${event.flagKey} -> ${event.variationKey} (${event.reason})`);
},
],
});
import { FeatureflipProvider } from '@featureflip/react';
function App() {
return (
<FeatureflipProvider
clientKey="your-client-sdk-key"
inspectors={[
(event) => {
console.log(`[flag] ${event.flagKey} -> ${event.variationKey} (${event.reason})`);
},
]}
>
{/* rest of your app, including components that call useFeatureFlag */}
</FeatureflipProvider>
);
}
import Featureflip
let config = FeatureflipConfig(
clientKey: "your-client-sdk-key",
inspectors: [
{ event in
print("[flag] \(event.flagKey) -> \(event.variationKey ?? "nil") (\(event.reason))")
}
]
)
let client = FeatureflipClient(config: config)
import dev.featureflip.android.FeatureflipClient
import dev.featureflip.android.FeatureflipConfig
val config = FeatureflipConfig(
clientKey = "your-client-sdk-key",
inspectors = listOf(
{ event -> println("[flag] ${event.flagKey} -> ${event.variationKey} (${event.reason})") },
),
)
val client = FeatureflipClient.get(config)
import 'package:featureflip/featureflip.dart';
final config = FeatureflipConfig(
clientKey: 'your-client-sdk-key',
inspectors: [
(event) {
print('[flag] ${event.flagKey} -> ${event.variationKey} (${event.reason})');
},
],
);
final client = FeatureflipClient.get('your-client-sdk-key', config: config);

A few things differ from the server SDKs described above.

Server SDKs evaluate locally, so their inspectors fire from inside the evaluator itself. Client SDKs never evaluate — they hold a snapshot of pre-evaluated results pushed down from the Evaluation API, and the app just reads out of it. So a client-SDK inspector fires from the 4 variation accessors (boolVariation / stringVariation / numberVariation / jsonVariation), after the accessor has type-checked the stored value and decided what to hand back to your code. Whichever snapshot-reading accessor a given SDK exposes — flagDetail(key) on Browser/Swift/Android, allFlags() on Flutter, or the underlying Browser client’s flagDetail(key) reached via React’s useFeatureflipClient() — is deliberately silent: it’s a diagnostic read of the snapshot, not an evaluation your app acted on.

Reason representation, plus a synthesized flag-not-found

Section titled “Reason representation, plus a synthesized flag-not-found”

Because the client SDK’s “evaluator” is just the server’s response, reason is the engine’s kebab-case string forwarded exactly as received: fallthrough, flag-disabled, rule-match:{id}, prerequisite-failed, error (see the reason table above). flag-not-found is the one reason a client SDK synthesizes rather than forwards from the server — it’s the client-side spelling of the same FlagNotFound / FLAG_NOT_FOUND outcome the server SDKs’ local evaluators already report — whenever the requested key isn’t in the client’s snapshot: an unknown key, a client that hasn’t finished initializing yet, or a flag that isn’t clientSideVisible (client SDKs only ever receive client-visible flags). On flag-not-found, variationKey, ruleId, and prerequisiteKey are all unset.

ruleId is derived the same way in every client SDK: it’s parsed out of a rule-match:{id} reason and left unset for every other reason — reason itself is never rewritten. Because the parsing rule and the event’s field names are identical to the server SDKs, one inspector function works unchanged whether it’s registered against a server SDK or a client SDK — including the PostHog/Amplitude/GrowthBook examples below and the prerequisiteKey debugging example at the end of this page.

Type mismatch: the one field combination that surprises people

Section titled “Type mismatch: the one field combination that surprises people”

Reading a flag with the wrong accessor — say, boolVariation on a flag whose value is actually a string — makes the accessor fall back to your default. The inspector event reports that fallback as value, but reason and variationKey still describe what the server actually served, because the server-side evaluation genuinely happened; only the local accessor rejected the type. Concretely, for a snapshot flag { value: "hello", variation: "v1", reason: "rule-match:rule-abc-123" }:

client.boolVariation('string-flag', false);
// returns false — your default, because the stored value isn't a boolean
// but the inspector event still shows what the server served:
// {
// flagKey: 'string-flag',
// value: false, // <- your default, NOT the server's value
// variationKey: 'v1', // <- a real variation WAS served
// reason: 'rule-match:rule-abc-123', // <- a real rule DID match
// }

That combination — value equal to your default while variationKey/reason describe a live, non-off variation — is the type-mismatch signal. Don’t confuse it with flag-not-found: a missing flag also serves your default, but leaves variationKey unset and reports the synthesized flag-not-found reason instead of a real one.

The singleton foot-gun: inspectors is first-caller-wins

Section titled “The singleton foot-gun: inspectors is first-caller-wins”

Every client SDK is singleton-by-construction: the underlying core is refcounted and shared per client key, so the first call to FeatureflipClient.get(...) (Swift: the first FeatureflipClient(config:); React: the first <FeatureflipProvider> mounted for a clientKey) constructs the shared core, and that call’s inspectors are the only ones that will ever fire for that key. A second get() (or provider mount) with the same client key — even one that passes different or additional inspectors — silently returns a handle to the already-constructed core and inherits whatever the first caller registered. On Browser, React, Android, and Flutter there is no warning specific to inspectors: config fields that are structurally compared (baseUrl, streaming, …) log a mismatch warning there, but inspectors is deliberately excluded from that comparison because functions aren’t structurally comparable. Swift is a step further removed: its core-acquisition path does no config comparison at all, for any field, so a repeat FeatureflipClient(config:) call for a live clientKey silently reuses the first config in every respect — not just inspectors — with no warning of any kind.

In practice: register every inspector your app needs at the one place that constructs the client for a given key. If your analytics wiring and your debug logging live in separate files, make sure only one of them actually calls get()/mounts the provider, and pass both inspectors there — a second, independent construction call for the same key will not add to the list.

inspectors: [
(event) => {
posthog.capture({
distinctId: String(event.context.user_id ?? 'anonymous'),
event: '$feature_flag_called',
properties: {
$feature_flag: event.flagKey,
$feature_flag_response: event.value,
variation: event.variationKey,
reason: event.reason,
},
});
},
]
inspectors: [
(event) => {
amplitude.track('flag_evaluated', {
flag_key: event.flagKey,
variation: event.variationKey,
value: event.value,
reason: event.reason,
}, { user_id: String(event.context.user_id ?? '') });
},
]
inspectors: [
(event) => {
experimentTracker.recordExposure({
unit: String(event.context.user_id ?? ''),
experiment: event.flagKey,
variation: event.variationKey ?? 'default',
});
},
]
  • Errors are isolated. If an inspector fails — a thrown exception, a Go panic, a Ruby raise — evaluation still returns the correct value, the other inspectors still run, and the SDK logs a warning. Your analytics failures never break a feature flag. The isolation guarantee holds identically on every client SDK, including Swift; the warning-on-throw specifically is Browser/React/Android/Flutter only — Swift’s EvaluationInspector is a non-throwing closure type, so an inspector cannot throw in the first place and there is nothing to catch or warn about.
  • Keep inspectors fast. They run synchronously in the evaluation call. For anything slow, enqueue the event and process it off the hot path.
  • Treat context as read-only. It is a shallow copy of the context you passed; mutating it has no effect on the SDK.

Because reason, ruleId, and prerequisiteKey are on the event, an inspector answers “why did this user get variation X?” without any extra tooling. The example below is the JS/Node server SDK; use your SDK’s own reason type for the comparison (see Reason values differ by SDK) — note that even though Browser and React are also JavaScript, their reason is the engine’s kebab-case string ('prerequisite-failed', not 'PrerequisiteFailed'), because a client SDK forwards the server’s reason verbatim instead of running its own evaluator:

inspectors: [
(event) => {
if (event.reason === 'PrerequisiteFailed') {
console.warn(`${event.flagKey} served its off value: prerequisite "${event.prerequisiteKey}" was not met`);
}
},
]