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, a trace span, an error report, 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 the JavaScript (@featureflip/js), Node (@featureflip/node), Python, C#, Go, Java, PHP, and Ruby server SDKs, 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',
});
},
]

Record evaluations on traces (OpenTelemetry)

Section titled “Record evaluations on traces (OpenTelemetry)”

When a request fails, the first question on call is often which variation it got. An inspector can answer that on the trace itself. Each recipe below adds a feature_flag.evaluation event to the span that is active when the flag is read, using the attribute names from the OpenTelemetry feature flag semantic conventions. The event is at Release Candidate status there.

AttributeValue
feature_flag.keythe flag key
feature_flag.provider.namefeatureflip
feature_flag.result.variantthe served variation, when there is one
feature_flag.result.reasontargeting_match, default (fallthrough), disabled, prerequisite_failed or error
error.typeflag_not_found or general, on errors only

The recipes leave out two optional attributes, feature_flag.context.id and feature_flag.result.value. A context id is usually a user id and a value can carry anything, so add them only if your traces may hold personal data. Outside a recording span every recipe returns before it allocates anything, and none of them makes a network call: the event travels with its span through the batching exporter you already run.

These recipes use span events because that’s what trace views such as Grafana Tempo and Jaeger show on a span today. OpenTelemetry plans to deprecate the span event API in favor of log-based events, and has said existing span events keep working until the next major SDK versions.

import { FeatureflipClient } from '@featureflip/node';
import { trace } from '@opentelemetry/api';
const REASONS = {
RuleMatch: 'targeting_match',
Fallthrough: 'default',
FlagDisabled: 'disabled',
PrerequisiteFailed: 'prerequisite_failed',
};
const ERROR_TYPES = { FlagNotFound: 'flag_not_found', Error: 'general' };
function recordFlagEvaluation(event) {
const span = trace.getActiveSpan();
if (!span?.isRecording()) return;
const errorType = ERROR_TYPES[event.reason];
span.addEvent('feature_flag.evaluation', {
'feature_flag.key': event.flagKey,
'feature_flag.provider.name': 'featureflip',
'feature_flag.result.reason': errorType ? 'error' : (REASONS[event.reason] ?? 'unknown'),
...(event.variationKey && { 'feature_flag.result.variant': event.variationKey }),
...(errorType && { 'error.type': errorType }),
});
}
const client = await FeatureflipClient.create({
sdkKey: process.env.FEATUREFLIP_SDK_KEY,
inspectors: [recordFlagEvaluation],
});

trace.getActiveSpan() needs a context manager. NodeSDK from @opentelemetry/sdk-node registers one for you. With a hand-built tracer provider, register AsyncLocalStorageContextManager yourself, or every evaluation will look like it happened outside a span.

from opentelemetry import trace
from featureflip import Config, EvaluationReason as R, FeatureflipClient
REASONS = {R.RULE_MATCH: "targeting_match", R.FALLTHROUGH: "default",
R.FLAG_DISABLED: "disabled", R.PREREQUISITE_FAILED: "prerequisite_failed"}
ERRORS = {R.FLAG_NOT_FOUND: "flag_not_found", R.ERROR: "general"}
def otel_inspector(event):
span = trace.get_current_span()
if not span.is_recording():
return
attrs = {"feature_flag.key": event.flag_key, "feature_flag.provider.name": "featureflip",
"feature_flag.result.reason": "error" if event.reason in ERRORS
else REASONS.get(event.reason, "unknown")}
if event.variation_key:
attrs["feature_flag.result.variant"] = event.variation_key
if event.reason in ERRORS:
attrs["error.type"] = ERRORS[event.reason]
span.add_event("feature_flag.evaluation", attrs)
client = FeatureflipClient(
sdk_key=os.environ["FEATUREFLIP_SDK_KEY"],
config=Config(inspectors=[otel_inspector]),
)
import io.featureflip.client.EvaluationInspector;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.api.trace.Span;
static final EvaluationInspector OTEL = event -> {
Span span = Span.current();
if (!span.isRecording()) return;
AttributesBuilder attrs = Attributes.builder()
.put("feature_flag.key", event.getFlagKey())
.put("feature_flag.provider.name", "featureflip");
if (event.getVariationKey() != null) {
attrs.put("feature_flag.result.variant", event.getVariationKey());
}
switch (event.getReason()) {
case RULE_MATCH: attrs.put("feature_flag.result.reason", "targeting_match"); break;
case FALLTHROUGH: attrs.put("feature_flag.result.reason", "default"); break;
case FLAG_DISABLED: attrs.put("feature_flag.result.reason", "disabled"); break;
case PREREQUISITE_FAILED: attrs.put("feature_flag.result.reason", "prerequisite_failed"); break;
case FLAG_NOT_FOUND:
attrs.put("feature_flag.result.reason", "error").put("error.type", "flag_not_found"); break;
case ERROR:
attrs.put("feature_flag.result.reason", "error").put("error.type", "general"); break;
default: attrs.put("feature_flag.result.reason", "unknown");
}
span.addEvent("feature_flag.evaluation", attrs.build());
};
FeatureflipClient client = FeatureflipClient.builder(System.getenv("FEATUREFLIP_SDK_KEY"))
.inspectors(List.of(OTEL))
.build();

The classic switch keeps it compiling on Java 11, the SDK’s minimum.

.NET traces are System.Diagnostics.Activity objects, and the OpenTelemetry .NET SDK exports an activity’s events as span events. The inspector needs no OpenTelemetry package of its own.

using System.Diagnostics;
using Featureflip.Client;
var options = new FeatureFlagOptions
{
Inspectors =
{
e =>
{
if (Activity.Current is not { IsAllDataRequested: true } activity) return;
var (reason, error) = e.Reason switch
{
EvaluationReason.RuleMatch => ("targeting_match", null),
EvaluationReason.Fallthrough => ("default", null),
EvaluationReason.FlagDisabled => ("disabled", null),
EvaluationReason.PrerequisiteFailed => ("prerequisite_failed", null),
EvaluationReason.FlagNotFound => ("error", "flag_not_found"),
EvaluationReason.Error => ("error", "general"),
_ => ("unknown", (string?)null),
};
var tags = new ActivityTagsCollection
{
["feature_flag.key"] = e.FlagKey,
["feature_flag.provider.name"] = "featureflip",
["feature_flag.result.reason"] = reason,
};
if (e.VariationKey is not null) tags["feature_flag.result.variant"] = e.VariationKey;
if (error is not null) tags["error.type"] = error;
activity.AddEvent(new ActivityEvent("feature_flag.evaluation", tags: tags));
},
},
};
var client = FeatureflipClient.Get(
Environment.GetEnvironmentVariable("FEATUREFLIP_SDK_KEY"),
options);

Featureflip’s own Management API runs this inspector in production, so its flag reads show up on our traces in Grafana Tempo.

Go’s variation methods take no context.Context, so an inspector has no way to find the active span. Use the Featureflip OpenFeature provider with OpenFeature’s own tracing hook, which reads the span from the ctx you evaluate with:

import (
featureflip "github.com/canopy-labs/featureflip-go-openfeature"
otelhook "github.com/open-feature/go-sdk-contrib/hooks/open-telemetry/pkg"
"github.com/open-feature/go-sdk/openfeature"
)
openfeature.AddHooks(otelhook.NewTracesHook())
_ = openfeature.SetProviderAndWait(featureflip.NewProvider(os.Getenv("FEATUREFLIP_SDK_KEY")))
client := openfeature.NewClient("my-app")
// ctx is the request context, carrying the active span
on, _ := client.BooleanValue(ctx, "new-checkout", false, evalCtx)

The hook writes the same feature_flag.evaluation event with the same reason values, with three differences. feature_flag.provider.name is featureflip-go, an error adds error.message, and it always includes feature_flag.context.id (the targeting key) and feature_flag.result.value. If those count as personal data for you, remove them in your collector before export.

Sentry can list the flags a request evaluated before it failed, on the error event itself. It records boolean flags only, so the recipes below skip every other type. Python, Java and browser JavaScript use Sentry’s OpenFeature integration with the matching Featureflip OpenFeature provider. Node has no OpenFeature integration in Sentry, so it uses an inspector instead.

import sentry_sdk
from sentry_sdk.integrations.openfeature import OpenFeatureIntegration
from openfeature import api
from featureflip_openfeature import FeatureflipProvider
sentry_sdk.init(dsn=os.environ["SENTRY_DSN"], integrations=[OpenFeatureIntegration()])
api.set_provider_and_wait(FeatureflipProvider(sdk_key=os.environ["FEATUREFLIP_SDK_KEY"]))

Install featureflip-openfeature-provider, openfeature-sdk and sentry-sdk (2.19.2 or later).

import dev.openfeature.sdk.OpenFeatureAPI;
import io.featureflip.openfeature.FeatureflipProvider;
import io.sentry.Sentry;
import io.sentry.openfeature.SentryOpenFeatureHook;
Sentry.init(options -> options.setDsn(System.getenv("SENTRY_DSN")));
OpenFeatureAPI api = OpenFeatureAPI.getInstance();
api.addHooks(new SentryOpenFeatureHook());
api.setProviderAndWait(new FeatureflipProvider(System.getenv("FEATUREFLIP_SDK_KEY")));

This needs io.featureflip:featureflip-openfeature and io.sentry:sentry-openfeature (8.42.0 or later). Sentry’s Java hook records successful evaluations only, so a flag that couldn’t be found never reaches the event.

import * as Sentry from '@sentry/browser';
import { OpenFeature } from '@openfeature/web-sdk';
import { FeatureflipProvider } from '@featureflip/openfeature-web';
Sentry.init({
dsn: 'your-sentry-dsn',
integrations: [Sentry.openFeatureIntegration()],
});
OpenFeature.addHooks(new Sentry.OpenFeatureIntegrationHook());
await OpenFeature.setProviderAndWait(
new FeatureflipProvider({ clientKey: 'your-client-sdk-key' }),
{ targetingKey: 'user-42' },
);

The browser hook records every boolean evaluation, including a flag that wasn’t found, which shows up with the default your code passed.

import * as Sentry from '@sentry/node';
import { FeatureflipClient } from '@featureflip/node';
const featureFlags = Sentry.featureFlagsIntegration();
Sentry.init({ dsn: process.env.SENTRY_DSN, integrations: [featureFlags] });
const client = await FeatureflipClient.create({
sdkKey: process.env.FEATUREFLIP_SDK_KEY!,
inspectors: [
(event) => {
// Sentry records boolean evaluations only
if (typeof event.value === 'boolean') {
featureFlags.addFeatureFlag(event.flagKey, event.value);
}
},
],
});

Keep a reference to the integration. addFeatureFlag is a method on it, not on the Sentry scope. The flags are stored on the current scope, so they’re kept apart per request only when Sentry’s request isolation is running, which its HTTP instrumentation sets up. Needs @sentry/node 9.31.0 or later.

As of November 2026, Sentry’s Go and .NET SDKs have no feature-flag API, so there is no recipe for them yet. The OpenTelemetry recipes above work in both.

  • 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, and never make a network call from one. They run synchronously inside every evaluation, so a request per evaluation adds its latency to every flag read in your app. Hand the event to something that batches, the way the recipes above do: an analytics SDK’s queue, a span event that leaves with its span, a Sentry scope that is read only when an error is sent. For anything else 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`);
}
},
]