Skill 51 · Diagnosing Experiment Health
Subchapter 51.1
references/bias-and-skew.mdMarkdown50 KBView on GitHub
Variant counts don’t match the configured split, one variant looks biased, the in-app warning banner appeared, or users are showing up under multiple variants.
Read three values first.
The first two come from the exposure data of the Step 1.5 snapshot (exposures in experiment-results-get), the third from experiment-get.
experiment-results-get runs queries: call it once, after the stored results and the change history of the snapshot, and reuse its exposures for every check in this file.
The first two are computed on the population the experiment analyzes, which a query of your own does not reproduce.
exposures.sample_ratio_mismatch.p_value.
The page flags a mismatch below 0.001.
interpretation.md).[MEDIUM].
A slow leak of exposures in one arm looks like this for days before it crosses the threshold.
No tool returns the p-value of an earlier day.
Read the series in exposures.timeseries, which is cumulative per variant: take the difference between days, and walk A9 when the gap grows in one direction.exposures.total_exposures['$multiple'] over the sum of all values.
Non-zero puts A1, A3 and A4 on the table, and A6 and A8 when the flag or the identifier changed during the run.
Under first_seen handling the key is absent, because each person keeps the first variant.feature_flag.filters.multivariate.variants[].rollout_percentage from experiment-get.
An uneven split amplifies whichever bias source is present.
The flag shows the split of today.
On an experiment that has ended, or whose flag was edited, take the split of the run from the change history (“Changes after launch” in diagnostic-snapshot.md).When the observed split matches the split the flag had during the run, there is no mismatch to explain.
A user who expects 50/50 on a flag that ran at 90/10 has a question about the configuration, not about bias: say which split the flag had and since when.
If the split changed after launch, walk E3 in mid-run-changes.md.
The product’s sample ratio test compares against the flag’s current split, so after such a change its p-value tests the wrong expectation.
If the symptom is “metric count is far smaller than exposures” (e.g. 10× or 100× gap), walk this
file before numbers-vs-sql.md — that shape of divergence is most often a bucketing / identity
problem (A3/A4), not a query-scope problem.
The SQL in this file follows the query rules in diagnostic-snapshot.md: the experiment’s exposure event, the flag’s variant keys, a window with both ends set, and no test-account filter.
The exposure event is resolved_exposure_event, unless exposure_criteria.exposure_config names $experiment_exposure: then it is that event (“Which event, which property” in diagnostic-snapshot.md).
When resolved_exposure_event is $feature_flag_called, also read $experiment_exposure (event IN ('$feature_flag_called', '$experiment_exposure')), and count persons, not rows.
The queries count person_id.
On a flag aggregated by a group type (feature_flag.filters.aggregation_group_type_index is set), put $group_<index> in its place and leave out the rows where it is empty.
When a result is compared with $multiple, leave out the keys in excluded_variants: the experiment never counts them.
resolved_exposure_event is always the default event.
On an experiment with a custom exposure event these queries read the flag’s calls, not the people the experiment counts: say so when you report them.
/flags variant disagreementdistinct_id strategy during a running experimentThis is the signal behind the in-app warning “The uneven split may bias the results”.
Evidence. The banner’s check reports a finding when all four hold:
multiple_variant_handling == "exclude" (the default)exposures.bias_risk.multiple_variant_percentage: the $multiple count over all exposures.The warning-vs-visible gap. The banner fires above 0.1%.
The collapsed summary of the Exposures panel leaves the $multiple row out at or below 0.5%.
The expanded table always lists it, labeled “(excluded from analysis)”.
So a user can see the banner while the summary shows a clean split.
The warning is real: point the user to the expanded table, and state the exact share from exposures.bias_risk.
Mechanism. Multi-variant users are dropped, but the smaller variant loses a larger fraction of its assignments than the larger variant. Multi-device / multi-session / signup-flow users tend to be high-intent — so the smaller variant keeps a low-intent slice and looks worse than it should. This is asymmetric exclusion bias, not a UI bug.
Recommend (in this order):
configuring-experiment-rollout. On a draft experiment this is
free. Mid-run it’s an anti-pattern — prefer reset or end+restart over changing the split mid-run.
The banner’s own “Adjust distribution” button opens the split editor on the running experiment.
A user who followed it changed the split mid-run: read the flag’s history before any other cause (E3 in mid-run-changes.md).multiple_variant_handling to "first_seen". See configuring-experiment-analytics.
Mid-run this is the low-disruption option — no users switch variants, all already-collected data
stays in the analysis. first_seen is less biased than exclude for this specific shape, not
unbiased: it counts the first variant a user saw and ignores later ones, which still
asymmetrically discounts engaged multi-session users. There is no clean fix for the underlying
problem; the trade-off is between which bias the user prefers.The experiment’s settings and the public docs call Exclude the recommended handling. That holds for an even split, and a user may quote it: the banner is the product’s own exception for uneven splits.
Evidence. exposures.sample_ratio_mismatch holds the p-value of a chi-squared test and the expected count per variant.
diagnostic-snapshot.md).$multiple, the holdout and excluded variants are left out.MUST read the product’s value. MUST NOT run the test on event counts of your own: raw exposure events include test accounts, responses that are no variant, repeated events of one person and events outside the analysis window.
What it means. The distribution of exposed people differs from the flag’s current split by more than chance allows. Either the split changed during the run (cause 1), or something biases the assignment or the capture of exposures. A visible gap on a small sample is not a mismatch until the test says so, and a mismatch on a small sample is still a mismatch: the test already accounts for the sample size. The exception is a single reading that clears on the next day’s data, which happens about once in a thousand readings by construction.
Causes, cheapest evidence first.
Surface each cause the evidence supports (Step 3 of SKILL.md).
The flag changed after launch.
Evidence: a change of the flag’s filters after start_date in experiment-activity.
Which edits move people, and which do not: A6.
Exposure events are lost or sent unevenly between the arms [MEDIUM].
The flag assigns evenly and the event does not arrive evenly: a redirect, an exposure placed after the arms diverge, a flag read before flags load.
It is the most frequent cause of a mismatch that no flag change explains.
See A9.
Part of the audience gets no variant.
Responses of false or an empty value are no exposures, and the people who drop out are not a random share.
The flag decides whether a person gets a variant apart from which variant, so a false from the flag removes people from every arm alike.
It moves the split only when something ties the loss to one arm, such as the application’s own fallback on a custom exposure event, or a pinned condition that still matches without a device id (A10).
See A10.
A release condition pins a variant.
Evidence: a variant in feature_flag.filters.groups[] that is one of the flag’s variant keys.
See A7.
Server and client evaluate the flag differently [MEDIUM].
A bootstrapped value against a later evaluation (A4).
A server that evaluates locally keeps the old definition for its polling interval after a flag edit.
A flag with persistence across authentication is not evaluated locally: the SDK falls back to a remote call, so the two paths can answer at different times with different inputs.
With strictLocalEvaluation or onlyEvaluateLocally set in posthog-node there is no remote call, and the call returns no value, which is no exposure.
Evidence: exposures per variant by library (snapshot query), feature_flag.ensure_experience_continuity, and whether the user’s servers evaluate locally.
Bots on server-side evaluation [LOW].
The public docs rank this first.
As a cause of a directional mismatch it is weak: one crawler is one identifier and counts once.
It matters when a crawler sends a new identifier with every request.
Evidence: the share of server-side libraries among the exposures, and exposed people with no later event.
Fix path: the “Filter Bot Events” transformation (a data pipeline transformation) applies its built-in bot lists to browser events only.
For events from a server SDK it filters only what the user adds as custom user-agent patterns or IP prefixes.
With “Keep events where the useragent is not set?” at No, it also drops every event without the user-agent property ($raw_user_agent by default), and posthog-node sets that property only when the code passes it.
The more reliable fix is to not evaluate the flag for requests the server already knows to be bots.
What does not cause a mismatch.
Fix paths change the flag.
An agent can edit a flag over MCP.
On a running experiment every such edit is a mid-run change (A6, and Step 4 of SKILL.md): explain the effect and get the user’s decision before any edit.
This is an identity problem, not a bias problem. One human has two or more distinct ids. Each id is hashed by itself, so each can land in a different variant.
$multiple.Evidence.
The count. exposures.total_exposures['$multiple'].
No event carries $multiple.
The analysis computes it per person, and only from the flag’s current variant keys: a response of false, an empty response or an old key never makes a person $multiple.
Under first_seen handling the count is not reported.
The query below is then the only source.
Ids per person. In the raw-responses query of the snapshot, distinct_ids / persons above 1 for a variant means that people evaluated the flag under several linked ids.
That is the precondition for a flip.
It says nothing about ids that were never linked.
The people, when the cause has to be named. Start with one day on a large project. The query finds only people with two variants inside its window, so an empty result on a short window does not rule out A3:
-- People whose exposure events carry more than one variant key
SELECT
person_id,
uniq(properties.$feature_flag_response) AS variants_seen,
uniq(distinct_id) AS distinct_ids,
groupUniqArray(properties.$feature_flag_response) AS variants
FROM events
WHERE event = '<resolved_exposure_event>'
AND properties.$feature_flag = '<flag-key>'
AND properties.$feature_flag_response IN ('<variant-key-1>', '<variant-key-2>')
AND timestamp >= toDateTime('<window_start>', 'UTC')
AND timestamp < toDateTime('<window_end>', 'UTC')
GROUP BY person_id
HAVING variants_seen > 1
ORDER BY variants_seen DESC, distinct_ids DESC
LIMIT 20One person’s exposure events, to tell the causes apart:
SELECT
timestamp,
distinct_id,
properties.$feature_flag_response AS response,
properties.$lib AS lib
FROM events
WHERE event = '<resolved_exposure_event>'
AND properties.$feature_flag = '<flag-key>'
AND person_id = '<person_id>'
AND timestamp >= toDateTime('<window_start>'
Common causes:
reset() was called between sessions (other than on logout)identify() ran after the flag was already evaluatedA note on what’s fundamentally fixable vs not. Stitched-identity issues from identify()
ordering, cross-domain cookies, and bootstrap timing are real bugs that can be fixed. Multi-device
usage and incognito / stealth browsing are not fixable from PostHog’s side — and the users who
exhibit them tend to be more engaged on average, so excluding the $multiple bucket pulls a
non-random slice out of the analysis. There is no clean fix; the recommendation is to contain the
problem (scope exposure to the relevant flow so the denominator stays meaningful) rather than
eliminate it.
Recommend:
identify() and reset() ordering — reset() only on explicit logout, identify() before
flag evaluation.
Persistence across authentication carries a variant from anonymous to identified, not back: after reset() the next evaluation is a new draw.person_profiles: 'always',
incompatible with local evaluation and bootstrapping, adds slight latency)false), which is the usual case for a server-side call that passes none (A10).
The exception is a release condition at a rollout of 100% that pins a variant: it still matches, and returns the pinned variant.
And it cannot be combined with persistence across authentication: the API refuses the pair.Specific scenario: server-rendered app with bootstrapping enabled. The $multiple share in this
shape can become substantial — well above the trickle you’d expect from normal cross-device traffic
alone. Website-only flags (no bootstrap) are unaffected.
Mechanism. Two shapes.
distinct_id, but the bootstrap
payload doesn’t include distinctID — so posthog-js initializes with whatever’s in persistence (often
the anonymous ID). The bootstrap value gets reported under the anonymous ID; then posthog-js asks
the flag service with whatever ID it has after identify(). When the IDs differ, the hashes differ,
and one person sends exposure events for two variants.
On a flag that buckets on the device id the identifier does not change at identify(), so this shape needs different device ids.[MEDIUM]. The bootstrap values come from the user’s own backend, which evaluates the flag with the properties it passes.
A property in another type or format than the stored one (a timestamp as a string against a stored number), or a variant set by hand, matches on one side only.
The bootstrap-sourced exposure carries a variant, and the next evaluation returns another variant or false.
The user sees the new experience for a moment and then loses it.Evidence. The exposure event carries two source properties:
$used_bootstrap_value — true when the value was read before the flags request of that page load answered.
That is a bootstrap value, and also a value cached from an earlier page load.
$feature_flag_bootstrapped_response holds what the bootstrap payload said for the flag, and is null without one.locally_evaluated — true when the event came from server-side local evaluation.The signature is one person with an event read before the flags request answered and another event for the same flag, with different variants.
$used_bootstrap_value is usually not a materialized column, so this query reads the full properties of every row: keep the window to one day.
The comparison with true needs the property typed Boolean in the project, which is the usual case.
If the query fails on a type error, write the two conditions as toString(properties.$used_bootstrap_value) = 'true' and ifNull(toString(properties.$used_bootstrap_value), '') != 'true'.
SELECT
person_id,
groupUniqArrayIf(properties.$feature_flag_response, properties.$used_bootstrap_value = true) AS bootstrap_variants,
groupUniqArrayIf(properties.$feature_flag_response, ifNull(properties.$used_bootstrap_value, false) = false) AS other_variants
FROM events
WHERE event = '<resolved_exposure_event>'
AND properties.$feature_flag = '<flag-key>'
AND properties.$feature_flag_response IN ('<variant-key-1>', '<variant-key-2>')
AND timestamp >= toDateTime('<window_start>', 'UTC')
AND timestamp < toDateTime('<window_end>', 'UTC')
GROUP BY person_id
HAVING length(bootstrap_variants) > 0
AND length(other_variants) > 0
AND arraySort(bootstrap_variants) != arraySort(other_variants)
LIMIT 20Rows here distinguish A4 from A3: A3 is identity fragmentation regardless of
source, A4 is specifically the bootstrap mismatch.
A stale cached value and a flag edit between two page loads give the same rows: confirm with $feature_flag_bootstrapped_response on the event, and with the flag’s history.
If the query returns no rows and
no events for the flag have $used_bootstrap_value = true anywhere, bootstrap is likely not in
play and A4 is unlikely — but absence isn’t definitive (older SDKs may not stamp the property).
Cross-check by asking whether the user’s app is server-rendered with bootstrapping enabled.
For the second shape, the non-bootstrap response is often false: drop the variant filter from the query to see it, and query $feature_flag_called, because $experiment_exposure holds no false response.
Recommend:
distinctID in the bootstrap payload when the server already knows the identity (e.g. logged-in
users).The experiment view shows one warning banner for a state in which the experiment and its flag disagree.
Five states exist, checked from experiment-get alone:
| Banner caption | State | What the banner says to do |
|---|---|---|
| “The experiment is paused” | Launched, no end date, the flag is disabled. status is paused | Resume or end the experiment |
| “The experiment is running, but no new users are being exposed” | Running, every release condition has a rollout of 0% | End with a conclusion, or increase the rollout. People exposed earlier stay in the results |
| “The experiment is running, but all users see a single variant” | Running, the first release condition matches everyone at 100%, and one variant holds 100% of the split | End with a conclusion, or adjust the variant distribution |
| “The experiment is not running, but users are exposed to multiple variants” | Ended, the flag is active, more than one variant is above 0%, and a release condition is above 0% | Disable the flag, or resume the experiment |
| “The experiment is not running, but users are exposed to multiple variants” | Not launched (also after a reset), not archived, the flag is active, more than one variant is above 0%, and a release condition is above 0% | Disable the flag, or start the experiment |
The table shows the page’s own rules. When the page shows the health findings, it takes the banner from the server’s flag-state finding instead. For an ended experiment, the flag_live_after_end finding also reads the flag’s calls and the other experiments on the flag:
For a draft, the server’s finding is absent when another experiment on the flag is running, because the flag’s state belongs to that experiment.
The flag_shared finding reports the draft instead.
Do not advise disabling the flag or starting the draft then (E15 in mid-run-changes.md).
“Adjust the variant distribution” on a running experiment is a change of the split, with what that does to the people already exposed (E3 in mid-run-changes.md).
Say so before the user restores a split.
What the banners do not cover:
mid-run-changes.md).status is exposure_frozen, and no banner appears (E16 in mid-run-changes.md).A flag that was active before launch.
Exposures before start_date are outside the analysis, and the hash is the same before and after launch, so they do not put people in more than one variant.
The risk is the SDK’s deduplication: a returning person who already sent the exposure event before launch may not send it again (B3 in empty-experiment.md).
Use managing-experiment-lifecycle for the correct lifecycle action.
A person’s variant follows from three things: the flag key, the identifier the flag buckets on, and the boundaries of the variant split. The variant key is not an input: a variant is a slot in the split, and the key is its label. The rollout of a release condition is drawn separately, so it decides whether a person is in, never which variant.
So flag edits after launch differ in what they do to a person who is already exposed:
| Edit after launch | Moves the person to another variant? | What the data shows |
|---|---|---|
| The variant split changes. Also: one variant to 0%, or the variants reordered | Yes, everyone whose draw lies between an old and a new boundary. That includes people of variants the edit did not touch | The same person under a second variant key: $multiple under exclude. A 0% variant stops growing |
| The bucketing identifier changes, or persistence across authentication is switched | Yes, for the people whose identifier changes. A persistence switch moves only those whose stored or anonymous id differs from their distinct_id. A new identifier is a new draw, and about half keep their variant by chance on a 50/50 split | $multiple rises from the day of the edit |
| A release condition is added, tightened, or its rollout changes | No. People move in or out. Out means the flag returns false, which is no variant | More or fewer new exposures. No $multiple from this edit alone |
| The same, when the condition a person now matches pins a variant | Yes, to the pinned variant | See A7 |
| A variant key is renamed | No. The same slot gets a new label. The flag API rejects the rename while the experiment runs | Exposures under the old key leave the analysis, because only current keys count |
| The flag’s key is renamed | Yes. The key is an input of the draw, so everyone gets a new one. The flag API allows it while the experiment runs | Exposures sent before the rename carry the old key and leave the experiment |
Evidence. experiment-activity returns the changes of the experiment and of its linked flag, each with the values before and after.
Look for a change of the flag’s filters after start_date, and compare multivariate.variants, groups and the bucketing identifier on both sides.
The same rows are in feature-flags-activity-retrieve, and in advanced-activity-logs-list when fields is left out or includes detail.changes.
advanced-activity-logs-list is offered only on a plan with audit logs.
What an edit does not prove.
A split change explains the people who were exposed on both sides of it.
People in more than one variant who were exposed only before or only after the edit have another cause, usually A3.
State the edit and its date. Do not attribute the whole $multiple share to it.
Recommend: treat a split change and an identifier change as contamination from the edit on.
Reset and relaunch is the cleaner fix.
Switching multiple_variant_handling to first_seen is the low-disruption mid-run option (per A1).
A rollout change alone needs no repair (E1 and E2 in mid-run-changes.md).
If the user is using release conditions to target specific cohorts to specific variants (e.g. iOS users see test, Android users see control), the resulting assignment is not random. PostHog’s statistics assume randomization, so this invalidates the standard significance interpretation.
PostHog doesn’t prevent this in the UI — but the user should understand that significance calculations are misleading in this setup. Nothing in the analysis leaves the pinned people out.
Verify directly. In experiment-get‘s response, scan
feature_flag.filters.groups[] for any entry where variant is one of the flag’s variant keys. That field is the
per-release-group variant override: any user matching that group’s properties[] is forced to
that variant rather than being randomly bucketed. A variant: null (or missing field) means the
group is randomized normally and A7 doesn’t apply.
Three rules decide who is pinned:
variant that is not one of the flag’s variant keys is ignored, and the person is bucketed normally.When the override exists, also check whether the targeted cohort overlaps the project’s
test-account exclusion list (B7 in empty-experiment.md says where to read it). If the cohort is in the exclusion list and exposure_criteria.filterTestAccounts is not false, those users are filtered
out of the analysis and the override is mostly a no-op for the metric (they were never going to
count). If the cohort is not excluded (e.g. an external partner’s email domain), the override
contaminates the variant assignment for real users.
When the exclusion runs through a dynamic cohort, it lags [LOW].
The flag evaluates the person’s properties live, and the analysis usually reads the cohort’s stored membership, which is recalculated on a schedule.
For some projects the analysis computes a fast cohort at query time, and then there is no lag.
People who are pinned today and not yet in the stored cohort count in the pinned arm.
Exclude on the person property itself in both places.
Recommend: if they need to compare cohorts, run separate experiments per cohort, or use a single
random assignment and analyze the cohorts as breakdowns of the same experiment (with the multiple-
comparisons caveats from references/interpretation.md). If the override exists by accident
(left over from QA / pre-launch validation), remove it: set variant: null on the affected
release group, or delete the group entirely. On a young experiment with little accumulated data,
reset + relaunch after the edit; on an experiment with significant clean data from before the
issue was noticed, treat the post-launch window as contaminated and consider end + relaunch.
The cohort-vs-cohort case above invalidates significance but still collects both variants. A worse
shape is a forced-variant release group whose properties are broad (or empty) at high rollout: it
captures most or all of the population, so the other variant receives almost no analyzable
exposures. Two shapes:
properties[]
(matches everyone) and a pinned variant at rollout_percentage: 100. Every user who doesn’t match
an earlier, narrower group falls through to it and is forced to that variant; the randomized
multivariate split never applies. One arm is orders of magnitude larger than the other, and the
small arm holds leftovers from earlier flag versions.created_at_unix >= <ts> → variant: test at 100%, where no release group leaves variant: null
and no group forces the other variant. Every new account is forced to test; the control arm
stops receiving new assignments and starves over time. Control collapses from a balanced share to
a trickle within weeks of the forced group being added, while test keeps growing.Detect (config-only, from experiment-get). Enumerate feature_flag.filters.groups[] and for each
read variant, properties (an empty array = catch-all matching everyone), and rollout_percentage.
Red flags, any of:
variant set and broad/empty properties at high rollout_percentage;variant: null or with a variant that is not one of the flag’s variant keys, i.e. nothing is randomized at all;No banner covers this shape. The single-variant banner of A5 reads the variant split, and a pinned group leaves the split untouched.
Confirm from exposures, and use the trend to read intent. exposures.total_exposures shows the
starved arm immediately as one variant’s persons being orders of magnitude below the other. Then read
the daily series (exposures.timeseries, cumulative by the day of first exposure); the shape tells you
what happened and is worth pulling before you characterize it:
This is distinct from the diagnostic-snapshot “plateau” (where the application stopped firing the
flag) — here the app still fires; the flag config forces the variant, so the cause is visible in
feature_flag.filters.groups[], not just the event stream.
Calibrate before reporting — this usually mirrors a rollout, not a bug. A broad set forcing a
variant at 100% is most often a deliberate rollout done through the flag instead of the experiment
UI (or a default being forced), with the experiment left in running status — not an accident. Two
things sharpen the read:
test (the new behaviour) = the new feature was rolled out to
everyone. Forcing control (the status quo) = the default was served to everyone, i.e. the feature
was effectively not shipped — worth surfacing as a question, since it’s easy to pin the wrong
variant (“did you intend users to get the new experience, or the status quo?”).Whatever the intent, while the flag forces a variant the experiment cannot produce a valid
control-vs-test readout, and its results page should not be read as an A/B. Recommend concluding the
experiment (read any pre-rollout balanced window as the result); if it was genuinely accidental,
removing the forced-variant group(s) and resetting restores randomization. Surface the finding and
confirm intent rather than asserting the experiment is “broken” (consistent with Step 4’s
don’t-assume-intent guidance in SKILL.md).
If the user is changing how distinct_id is sent (e.g. anonymous → identified user ID, or
email-as-ID → stable user ID, or a different identifier altogether) while an experiment is running,
the affected people are drawn again the next time the flag is evaluated.
The variant comes from a hash of the flag key and the identifier the flag buckets on.
For a flag that buckets on the person, without persistence across authentication, that identifier is the distinct_id: a new id is a new draw, and on a 50/50 split about half of the affected people land in the other variant.
Not affected: a flag that buckets on the device id, and a group-aggregated flag. Their identifier does not change.
What the data shows.
When the old and the new id are linked to one person, the person counts as $multiple.
When they are not linked, the analysis sees a second person, and nothing flags it.
The migration is a change in the user’s code, so no activity log shows it.
Recommend:
$multiple and treat the affected window
as contaminated — use reset + relaunch once the migration is complete.Symptom (in the user’s vocabulary): “the split is 58/42 and should be 50/50”, “one variant gets fewer users every day”, “our own analytics show an even split and PostHog does not”. Often with the mismatch warning, sometimes with a p-value that is low and not yet below the threshold.
Behavior. The flag assigns evenly. The exposure event does not arrive evenly, because the arms differ in what happens around the moment the event is sent:
empty-experiment.md).
In posthog-js an early read returns nothing only when no flags are cached from an earlier page load, so this loss falls on people with no cached flags, mostly first visits.[LOW].
A page that is cached with a variant or an identifier inside hands one assignment to many browsers.Evidence to gather.
diagnostic-snapshot.md).
A gap in one library, or from one day on, names the code path or the deploy.
The same query by page shows an exposure that one arm sends from a page the other arm never reaches.exposure_criteria.exposure_config.properties that only one arm’s events can match gives the same picture, and is a setting, not code (B12 in empty-experiment.md).empty-experiment.md).
No tool splits exposures this way: add person.created_at < toDateTime('<start_date>', 'UTC') AS returning to the SELECT and the GROUP BY of the “By day and library” query in diagnostic-snapshot.md, and compare the arms within each group.
B3 removes returning people from every arm in proportion, so a gap in one arm points here.Co-occurs with: A2 (this is its most frequent cause), A10, and B8 in empty-experiment.md.
Recommendation. Send the exposure at a point that both arms pass before they diverge.
$feature/<flag-key> (B4 in empty-experiment.md).The results collected so far carry the bias. After the fix, reset and relaunch, or move the start of the analysis to the fix.
Symptom (in the user’s vocabulary): “most responses on the flag are false“, “users saw the new UI for a second and then lost it”, “far fewer exposures than visitors on that page”, “control is much bigger than it should be”.
Behavior. Only an exposure whose response is one of the flag’s variant keys counts.
A response of false, an empty response, or a key that is no variant is no exposure, and nothing warns about it.
The people who get no variant are rarely a random share, so the people who stay are a biased sample, and the arms can lose different shares.
Why an evaluation returns no variant:
person_profiles: 'identified_only', anonymous visitors have no stored person properties: the flag sees only what the client sends with the call.false for a call that carries none, which is the usual case for a server-side call.
A release condition at a rollout of 100% that pins a variant still matches without a device id, so such calls carry only the pinned variant.false for a call that carries no key for that group type.[MEDIUM].
Code that reports “no variant” as control puts everyone outside the rollout into control, in every count that the application fills: the user’s own analytics, and a custom exposure event that carries the application’s value.
Control there holds every person the flag gives no variant, on top of its own share of the people with a variant.
The default exposure event is not affected: its response stays false and is no exposure.holdout-<id> in place of a variant.
That is intended: they are outside the experiment.Evidence to gather.
$feature_flag_called by day and by library (“By day and library” in diagnostic-snapshot.md, with $feature_flag_called as the event): the share that is no variant key, and whether it sits in one library.
$experiment_exposure holds no false or empty response, so it cannot show these rows.feature_flag.filters.groups[].properties: a condition on a property that is set after sign-up, or by the backend.feature_flag.bucketing_identifier.false share that equals what the release conditions exclude by design is not a finding.Co-occurs with: A2, A4, A9, and B10 in empty-experiment.md.
Recommendation, in order of preference:
distinct_ids: an identity cause from the list below.distinct_id, and a flag edit between the two timestamps: A6.distinct_id, no edit, different libraries: server and client disagree (A4, A10).A response of false has no $experiment_exposure row: when resolved_exposure_event is $experiment_exposure, run the query on $feature_flag_called to see a step to no variant.