Diagnosing Experiment Health
PostHogOfficialPublished by PostHog
- Skill
- Review and audit
- Data and AI
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured diagnostic framework for investigating PostHog experiment results. It helps users identify why experiments might appear biased, empty, or statistically inconsistent by analyzing configuration, exposure data, and activity logs. It guides the user through a systematic verification process, moving from high-level configuration checks to detailed statistical interpretation, ensuring that reported issues are grounded in verifiable data rather than assumptions.
What it does
- Resolves experiment IDs and retrieves current configuration settings.
- Pulls diagnostic snapshots to verify experiment state without user interviews.
- Identifies causes for bias, sample ratio mismatches, and uneven variant splits.
- Troubleshoots empty experiments and zero-exposure scenarios.
- Interprets statistical significance, A/A test results, and potential interpretation traps.
- Compares PostHog experiment numbers against raw SQL or custom dashboard counts.
When to use it
- When an experiment shows unexpected bias or a sample ratio mismatch warning.
- When an experiment reports zero exposures or results that seem empty.
- When results appear statistically significant or insignificant in ways that contradict expectations.
- When PostHog numbers disagree with external SQL queries or internal dashboards.
- When results change unexpectedly after mid-run configuration edits.
Advantages
- Reduces guesswork by prioritizing data-backed verification over user reports.
- Provides a clear, step-by-step methodology for complex experiment debugging.
- Distinguishes between systematic bias and statistical noise.
- Ensures recommendations are scoped to the current state of the experiment.
Usage examples
- “Why is my experiment showing a sample ratio mismatch?”
- The agent will pull the experiment configuration and exposure snapshot to check the p-value of the sample ratio test and identify if the split changed during the run.
- “My experiment has 0 exposures, what is wrong?”
- The agent will investigate the exposure criteria, SDK call timing, and potential filtering issues to determine why users are not being captured.
- “The results changed after I edited the experiment, can you explain why?”
- The agent will review the experiment activity log and stored results to explain the impact of the mid-run change on the data collected.
- “Is my A/B test statistically significant?”
- The agent will evaluate the current sample size, significance method, and potential interpretation traps like peeking or low-volume variance.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/posthog/diagnosing-experiment-health/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip diagnosing-experiment-health.zip -d .agents/skills
Documentation by PostHog
Diagnosing experiment health
This skill answers: My PostHog experiment results look wrong, biased, or empty — what's going on? It is the diagnostics and health check for one experiment: its setup, its exposures, its results and what changed during the run.
Match the user's complaint in the dispatch table, then read the matching reference file for the diagnostic.
Each diagnostic in the reference files is tagged [HIGH], [MEDIUM], or [LOW] based on how
strongly it's verified — [HIGH] is verified directly in PostHog code, [MEDIUM] is partially or
team-source verified, [LOW] describes SDK/external behavior that wasn't verified here. Treat [LOW]
items as hypotheses to test, not facts to assert.
Step 1 — Resolve the experiment
If the user refers to an experiment by name or description, load the finding-experiments skill first to
resolve it to a concrete ID.
Call experiment-get and pull these fields. They are inputs for almost every diagnostic:
status(draft/running/paused/exposure_frozen/stopped),start_date,end_date,is_legacyfeature_flag.active, andfeature_flag.filters.multivariate.variants[]— the variant keys and the split (rollout_percentage). This is the flag as it is now. After a ship or an edit, the split of the run is in the change history (Step 1.5).feature_flag.filters.aggregation_group_type_index— when set, the experiment's unit is a group, not a personfeature_flag.filters.groups[]— for each group readvariant,properties, androllout_percentage(that group's rollout, % of the matched bucketing units — persons, or groups when the flag or the group is aggregated by a group type — that enter the experiment; each group has its own, so there is no single overall rollout unless the flag has one unconditional group). A non-nullvariantthat is one of the flag's variant keys is a forced-variant override on the matched cohort (release-condition assignment, not randomized) — surfaces A7. Watch for the severe shape (A7b): a variant-pinned group with broad/emptypropertiesat high rollout, or no group left randomized (variant: null) / no release path to one arm — that starves the other variant (one arm gets ~0 analyzable exposures). Seereferences/bias-and-skew.md.feature_flag.bucketing_identifierandfeature_flag.ensure_experience_continuity— what the flag hashes (A3, A6, A8, A10)holdoutandexcluded_variants— people and arms that are outside the analysis on purposeexposure_criteria.multiple_variant_handling— defaults to"exclude"if absentexposure_criteria.exposure_config— the exposure event and its property filters. Unset, or naming$feature_flag_called, means the default exposure event: read which one fromresolved_exposure_event. When it is$feature_flag_called, also read$experiment_exposure(event IN ('$feature_flag_called', '$experiment_exposure')), and count persons, not rows. A config that names$experiment_exposurecounts that event, whateverresolved_exposure_eventsays. Any other event, or an action, is a custom exposure event. Property filters in the config apply to either (B12). The table inreferences/diagnostic-snapshot.mdsays which property carries the variant.exposure_criteria.activation_config— an activation event, or an action, on top of the default exposure. When it is set, a person counts as exposed only from their first activation event at or after their first flag exposure, and the time of that event is the exposure time. It does not combine with a custom exposure event.exposure_criteria.filterTestAccounts— defaults totruemetricsandmetrics_secondary, with shared metrics — for each: the name, the type and the definition. Describe a metric from its definition, not from its name (D13).stats_config—method(Bayesian or Frequentist), the confidence level (bayesian.ci_level,frequentist.alpha),frequentist.sequential_testing_enabled,cuped,baseline_variant_key. A missingmethodreads as Bayesian and a missing confidence level as 95%. Sequential testing and CUPED fall back to the project default (C13).running_time_calculation— the target sample (a total across all variants), the days still missing to reach it, and the minimum detectable effect. When the estimate comes from numbers the user typed, or was written at creation and never refreshed by the page, the days are the whole estimated length (C12). A duplicated experiment starts with the values of its source until the page refreshes them.only_count_matured_users— E11
Step 1.5 — Pull a diagnostic snapshot (verify before asking)
Before asking the user clarifying questions, pull the diagnostic snapshot in references/diagnostic-snapshot.md. Most diagnostics in this skill can be confirmed or ruled out from that data without an interview.
Read the cheap sources first, in every diagnosis: the stored results, then the change history. When the user reports a surprising change, read the change history before the stored results. Then the exposure data, when the diagnosis needs what only it holds. Query raw events only for what those cannot show.
Step 2 — Match symptom to diagnostic
| User says... | Diagnostic group |
|---|---|
| "Smaller variant looks biased" / banner says bias | A — bias & skew |
| "Variant ratio doesn't match my split" / mismatch warning / "is 58/42 normal?" | A — bias & skew |
| "Why isn't it 50/50?" / "one variant gets fewer users every day" | A — bias & skew |
"Users in both control and test" / high $multiple % | A — bias & skew |
| "Our own analytics show an even split and PostHog doesn't" | A — bias & skew |
| "Most responses on the flag are false" / "users saw the new UI for a second, then lost it" | A — bias & skew |
| Multi-variant exposure on a server-rendered app | A — bias & skew |
| Banner about feature-flag/experiment state mismatch | A — bias & skew |
| "Migrating distinct_id" / "switching from anonymous to user_id" mid-run | A — bias & skew |
| Metric count is much smaller than exposures (e.g. 10× or 100× gap) | A — bias & skew (route here before D) |
| "Experiment shows 0 / not enough data" / empty | B — empty experiment |
| "Variant always undefined / false" | B — empty experiment |
| "$feature_flag_called fires but no exposures show up" | B — empty experiment |
| "Exposures dropped to zero after I added a filter" / "the copied experiment is empty" | B — empty experiment |
| "Plenty of exposures, zero conversions" | B — empty experiment (then D11) |
| "Experiment says running but exposures haven't moved in weeks/months" | B — empty experiment |
| "Significance keeps flipping as we run longer" | C — interpretation traps |
| "Significance was declared, then it wasn't significant anymore" | C — interpretation traps |
| "It's been running one day, can we read it?" | C — interpretation traps |
| "30/16 split at 46 exposures, is this broken?" | C — interpretation traps |
| "It says complete / target reached and nothing is significant" | C — interpretation traps |
| "A/A test is showing significant results" | C — interpretation traps |
| "Many metrics — some significant, some not" | C — interpretation traps |
| "The test variant does worse, is it set up correctly?" / "can you check this experiment?" | Step 3 — the two checks, then C and D12 |
| "Bayesian says 96% chance to win — should we ship?" | C — interpretation traps |
| "Confidence intervals overlap — does that mean not significant?" | C — interpretation traps |
| "The p-value is exactly 1" / "control shows a lift" | C — interpretation traps |
| "An external tool (significance calculator or AI agent) disagrees with PostHog" | C — interpretation traps |
| "Should I ship? Primary is up but a secondary is down" | C — interpretation traps |
| "PostHog numbers ≠ my SQL count" | D — numbers vs SQL |
| "Funnel says X% but my raw event count says Y" | D — numbers vs SQL |
| "The experiment says flat and our dashboard shows a drop" | D — numbers vs SQL |
| "Sum of revenue looks wrong" / "breakdown shows 'none'" | D — numbers vs SQL |
| "The metric's name says one thing and the number says another" | D — numbers vs SQL |
| "Recordings don't match the stats" / "no recordings for exposed users" | D — numbers vs SQL |
| "I applied a filter but the user count didn't change" | D — numbers vs SQL |
| "I want to slice results by current person properties (as of now, not as of exposure)" | D — numbers vs SQL |
| "The results haven't updated" / "refresh changes nothing" | D — numbers vs SQL |
| "Changed split / rollout / metric / criteria mid-run, now odd" | E — mid-run changes |
| "The numbers changed overnight and nobody touched it" | E — mid-run changes, then D8 in D |
| "We paused it for a few days, can we still trust it?" | E — mid-run changes |
| "Froze the experiment" / "after the freeze users lost the variant" / "freeze was refused" | E — mid-run changes |
| "Ended/shipped — flag now flipped to 0/100 unexpectedly" | E — mid-run changes |
| "We shipped the variant and some users still don't get it" | E — mid-run changes |
| "Long-term metric moves opposite from primary" | E — mid-run changes |
| "Retention metric counts users I didn't expect" | E — mid-run changes |
| "Can't convert the feature flag back to a simple (boolean) flag after the experiment ends" | E — mid-run changes |
| "How do I restart an experiment with new variants?" | E — mid-run changes |
| "It says this is a legacy experiment" / metrics can't be edited | E — mid-run changes (E13 legacy experiments) |
"Results won't load" / many metric rows show data: null (not a legacy experiment) | Step 1.5 — diagnostic snapshot (null rows) |
| "What do users think of the new flow?" / wants qualitative feedback on an experiment | F — qualitative feedback |
| "Why did users prefer control?" / "what did they dislike about the test variant?" | F — qualitative feedback |
If the symptom is unclear, ask one clarifying question before picking. Most diagnostics have different fixes — do not guess. A complaint that matches rows of several groups gets each of them read (Step 3).
A report from a scout or another automated check names its finding: route by the finding, and reuse the report's numbers before pulling them again.
Step 3 — Surface every diagnostic the evidence supports
After matching the symptom in Step 2 and reading the relevant reference file(s), list each diagnostic that applies before recommending an action.
Surface co-occurring mechanisms independently — even when one is more salient, don't collapse them into a single "wait" or "fix" recommendation. Different mechanisms have different fixes: a systematic bias (e.g. uneven-split + Exclude) doesn't resolve by waiting; a statistical pattern (e.g. small-sample variance) does. Bundling them leaves the bias in place after the user follows the bundled advice.
Only list mechanisms that have a path to verification in the project state — config (from
experiment-get), snapshot data, activity log, or repo source (when the agent has the repository). Config-derived mechanisms count: an
80/20 split with default multiple_variant_handling="exclude" is visible in experiment-get and is
therefore enumerable. Naming a mechanism with no source (e.g. SRM when the snapshot shows a clean
variant ratio) is not.
Two checks belong to every diagnosis, whatever the complaint:
How young is the experiment? State its age (
start_date) and its share of the target sample (the exposed total againstrunning_time_calculation.recommended_sample_size) before reading a result (C1 inreferences/interpretation.md). The page takes the exposed total from the first primary metric: the sum of itsnumber_of_samplesover all variants in the stored results, notexposures.total_exposures.recommended_sample_sizeis the target the page saved when it last computed the estimate. When it is absent, no target was saved: say so.Who is exposed? Compare the exposed population (
exposure_criteriaand the flag's release conditions) with the people the decision is about before calling the setup sound (D12 inreferences/numbers-vs-sql.md). Default exposure settings are not evidence that the right people are in the experiment. When nothing says who the decision is about, ask.
Diagnostic groups
A — Bias & skew
Variants don't look balanced, one variant looks biased, the in-app warning banner appeared, or users are
showing up under multiple variants. Covers the uneven-split + Exclude interaction, the sample ratio mismatch and its causes, exposure events that are lost or sent unevenly between the arms, flags that return no variant for part of the audience, identity
fragmentation, bootstrap × /flags mismatch, mid-run flag edits, forced variants, and flag/experiment state inconsistency.
→ See references/bias-and-skew.md
B — Empty experiment / 0 exposures / "not enough data"
A frequent pain point. Covers SDK call (wrong evaluation method, identify() timing, dedup),
exposure capture (custom event missing variant property, required properties, ad-blockers), and
exposure-criteria match (test-account filter, filters that cannot match, eligibility ordering, events firing before exposure).
→ See references/empty-experiment.md
C — Significance / interpretation traps
Significance flipping, A/A test showing significance, Bayesian vs Frequentist confusion, multiple comparisons, low-volume variance, peeking / early stopping. Includes the running-time estimate ("target reached" is not a result) and the statistics settings that change how a result reads: sequential testing, CUPED, the confidence level and the baseline variant.
→ See references/interpretation.md
D — Numbers don't match (PostHog vs the user's SQL / raw count)
The experiment page applies an exposure scope, $multiple exclusion, test-account filter, and date range
that ad-hoc SQL almost never replicates. Covers funnel attribution (only first→last step counts for stats),
breakdowns (read from the exposure event, not the metric event), the "sum of revenue" mean-of-per-user
confusion, stale stored results, the Recordings tab against the stats, an insight that counts a narrower population than the experiment, and a metric whose title does not match its definition.
→ See references/numbers-vs-sql.md
E — Surprises after mid-run changes (incl. lifecycle and retention quirks)
Increasing rollout is safe; decreasing is caution; changing the variant split is an anti-pattern; adding metrics mid-run is p-hacking; ship-variant rewrites the flag and ending alone does not; reset clears results not the flag (apart from removing an exposure freeze); a pause, a freeze and an unfreeze can each bias the result; a changed start date moves the whole analysis. Also covers retention-metric quirks, "matured users" filtering, and long-term vs short-term metric divergence.
→ See references/mid-run-changes.md
F — Qualitative feedback: how the change landed, not how far the number moved
Groups A–E find mechanisms. F is for the question they can't reach: what the people in the experiment made of the change. A short survey, shown when users finish the experimented flow, adds that qualitative half — a rating and an optional comment, readable per variant — and works over MCP today.
It suits some experiments and not others. The gate is whether a user could describe the change without being shown both versions: a reworked flow, layout, or process work; a threshold or ranking tweak don't, however large its measured effect. Don't offer it while a mechanical diagnostic is still open — a survey on top of a broken flag gate collects opinions about a feature half the audience never received — and check whether an existing or recent survey already covers the window before proposing a new one.
Two things to get right before creating one: a survey shown to a single variant is itself a difference between the variants, and a running survey linked to the flag can generate exposure events.
→ See references/qualitative-feedback.md
Step 4 — Calibrate recommendations to experiment state
Surface diagnostics first (Step 3). Then recommend — but scope what you recommend to what the experiment's current state permits.
Draft — config changes are free; recommend and apply. The flag of a draft can still serve users: a draft made by a reset, or created on an existing flag, can have
feature_flag.activetrue, and an edit of that flag reaches those users at once.Running (and
pausedorexposure_frozen, which are launched and not ended) — every change has a tradeoff. Explain the mid-run impact (anti-pattern? safe? user-visible?) before recommending. Seeconfiguring-experiment-rolloutand its reference filereferences/changing-distribution-after-launch.mdfor the mid-run rules. A pause, a freeze, an unfreeze and a reset are changes too: each one interrupts the run and can bias the result (E8, E9 and E16 inreferences/mid-run-changes.md).Stopped / archived — the experiment AND its feature flag represent the documented outcome of the run. Recommendations are scoped to (a) interpretation of the existing data, (b) what to do for the next experiment, or (c) explaining what happened.
A diagnosis ends with findings and a recommendation. A change to a running experiment or to its flag (a new metric, new exposure criteria, a reset, a relaunch, a flag edit) is the user's decision: say what the change does to the data collected so far, and make it only on an explicit instruction.
On a stopped or archived experiment, don't preemptively offer reversal of a state mutation (ship-variant flag rewrite, manual flag edit, reset, archive). If the user asks "why did X happen?", explain X — don't append a "here's how to undo it" coda. That pattern assumes intent the user didn't signal. Conditional offers like "if this wasn't intended, you could…" or "want me to revert it?" count as preemptive too — only the user explicitly naming the reversal action ("how do I undo this?", "can I roll back ship-variant?", "how do I get the 50/50 split back?") is a request to surface reversal mechanics.
Use consistent terminology: variant split (between variants) is distinct from rollout (the share of a release condition's audience that
enters); the default exposure event (resolved_exposure_event) is distinct from a custom exposure event; the
Exclude from analysis / Use first seen variant options of Multiple variant handling (exposure_criteria.multiple_variant_handling) decide how people exposed to several variants are counted, not which event is the exposure.
Related skills
configuring-experiment-analytics— fix the exposure or metric configuration the diagnosis points atconfiguring-experiment-rollout— split-change anti-patterns and safe rollout adjustmentsmanaging-experiment-lifecycle— reset, end, or restart when the experiment can't be salvaged in placeanalyzing-experiment-session-replays— when the numbers are fine but you need to see the behavior behind themdebugging-experiments— when the question arrives as a support request about someone else's experiment and the answer is a written reply
Files
- ATTRIBUTION.mdAdded by AutoGineer875 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md23.3 KiB
- references/bias-and-skew.md49.9 KiB
- references/diagnostic-snapshot.md20.7 KiB
- references/empty-experiment.md33.7 KiB
- references/interpretation.md30.3 KiB
- references/mid-run-changes.md32.4 KiB
- references/numbers-vs-sql.md36.5 KiB
- references/qualitative-feedback.md17.8 KiB
references/bias-and-skew.md
Bias & skew on a running experiment
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.
Before diagnosing
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.
The sample ratio test:
exposures.sample_ratio_mismatch.p_value. The page flags a mismatch below 0.001.Below 0.001: a real assignment or capture problem, or a split that changed during the run, because the test compares the whole run with the current split. Walk A2.
At or above 0.001: no mismatch by the product's rule. On a small sample the visible split is noise (see C2 in
interpretation.md).A value between 0.001 and 0.05 is not cleared by that rule
[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 inexposures.timeseries, which is cumulative per variant: take the difference between days, and walk A9 when the gap grows in one direction.
People in more than one variant:
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. Underfirst_seenhandling the key is absent, because each person keeps the first variant.The configured split.
feature_flag.filters.multivariate.variants[].rollout_percentagefromexperiment-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" indiagnostic-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.
Contents
A1 — Multi-variant exclusion bias on uneven split
A2 — Sample ratio mismatch (SRM)
A3 — Identity fragmentation (users in both control and test)
A4 — Bootstrap ×
/flagsvariant disagreementA5 — Flag/experiment state inconsistency
A6 — Mid-run flag edits that rebucket already-exposed users
A7 — Non-randomized assignment via release conditions
A7b — A forced-variant group starves the other arm
A8 — Migrating the
distinct_idstrategy during a running experimentA9 — Exposure events are lost or sent unevenly between the arms
A10 — Part of the audience gets no variant from the flag
A1 — Multi-variant exclusion bias on uneven split [HIGH]
This 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)the variants that the flag serves and the analysis keeps have shares more than one point apart. A variant at 0% and an excluded variant don't count, so a flag at 0/50/50 is even, and a flag that serves one variant gets no finding.
the share of people in more than one variant is above 0.1%, and at least 5 people are in more than one variant. The value is
exposures.bias_risk.multiple_variant_percentage: the$multiplecount over all exposures.the experiment has no end date. After the end the check reports nothing, and the banner is gone. The exclusion is still in the numbers: the banner's absence on an ended experiment clears nothing.
<!-- Source for maintainers (may rot): evaluate_bias_risk and MULTIPLE_VARIANT_BIAS_THRESHOLD in products/experiments/backend/health/checks/bias_risk.py; the end-date rule in _evaluate_bias_risk, products/experiments/backend/hogql_queries/experiment_exposures_query_runner.py. Verify before citing. -->
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.
<!-- Source for maintainers (may rot): MULTIPLE_VARIANT_WARNING_THRESHOLD and filterLowMultipleVariant in frontend/src/scenes/experiments/utils.ts; Exposures.tsx in frontend/src/scenes/experiments/ExperimentView. -->
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):
Switch to an equal split. See
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 inmid-run-changes.md).Switch
multiple_variant_handlingto"first_seen". Seeconfiguring-experiment-analytics. Mid-run this is the low-disruption option — no users switch variants, all already-collected data stays in the analysis.first_seenis less biased thanexcludefor 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.
A2 — Sample ratio mismatch (SRM) [HIGH]
Evidence. exposures.sample_ratio_mismatch holds the p-value of a chi-squared test and the expected count per variant.
The test runs once the variants with a rollout above 0% hold 100 exposures together.
With fewer than two variants above 0% there is no test, and the value is null. That is the state after a ship and after a split edited to 100/0. Judge such an experiment by the split of the run ("Changes after launch" in
diagnostic-snapshot.md).The page flags a mismatch at p < 0.001. The server returns the p-value and applies no threshold.
The expected counts follow the flag's split as it is now. They are rescaled for a holdout.
$multiple, the holdout and excluded variants are left out.After a split change the test compares the whole run with the new split, so a mismatch is the expected reading. Read A6 before anything else in that case.
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.
<!-- Source for maintainers (may rot): _calculate_srm in products/experiments/backend/hogql_queries/experiment_exposures_query_runner.py; sample_ratio_test and SRM_MINIMUM_SAMPLE_SIZE in products/experiments/backend/health/checks/srm.py; hasSampleRatioMismatch in products/experiments/frontend/health/exposureHealth.ts. -->
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
filtersafterstart_dateinexperiment-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
falseor 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 afalsefrom 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
variantinfeature_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. WithstrictLocalEvaluationoronlyEvaluateLocallyset 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_agentby 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.<!-- Source for maintainers (may rot): nodejs/src/cdp/templates/_transformations/bot-detection/bot-detection.template.ts (is_browser_traffic). The public docs rank bots first: https://posthog.com/docs/experiments/troubleshooting#diagnosing-sample-ratio-mismatch-srm -->
What does not cause a mismatch.
Repeated exposure events of one person. The experiment counts each person once, at the first exposure. A server SDK that sends the event again after a restart or after its dedup cache fills up changes the event count, not the split.
Ad-blockers and network loss. Both arms lose alike. The sample shrinks, and a reverse proxy recovers part of it.
A holdout. The expected counts are rescaled for it.
People in more than one variant, on an even split. They leave both arms alike. On an uneven split see A1.
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.
A3 — Identity fragmentation (users in both control and test) [MEDIUM]
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.
When the ids are linked to one person, the analysis sees one person with two variants and counts the person as
$multiple.When the ids were never linked, the analysis sees two persons with one variant each. Nothing in the exposure data shows it.
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 offalse, an empty response or an old key never makes a person$multiple.Under
first_seenhandling 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 / personsabove 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>', 'UTC') AND timestamp < toDateTime('<window_end>', 'UTC') ORDER BY timestamp LIMIT 50Different
distinct_ids: an identity cause from the list below.The same
distinct_id, and a flag edit between the two timestamps: A6.The same
distinct_id, no edit, different libraries: server and client disagree (A4, A10).
A response of
falsehas no$experiment_exposurerow: whenresolved_exposure_eventis$experiment_exposure, run the query on$feature_flag_calledto see a step to no variant.
Common causes:
reset()was called between sessions (other than on logout)identify()ran after the flag was already evaluatedCross-device usage without identity stitching
Cookies cleared between visits, incognito / stealth browsing
Anonymous → identified transition without flag persistence enabled
The same user has different anonymous IDs client-side vs server-side, so the flag hash bucket differs
Native mobile auth flows where the flag is read before the SDK identifies the user, or where authentication crosses an SDK boundary (e.g. web → in-app webview)
A 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:
Audit
identify()andreset()ordering —reset()only on explicit logout,identify()before flag evaluation. Persistence across authentication carries a variant from anonymous to identified, not back: afterreset()the next evaluation is a new draw.For experiments spanning logged-out → logged-in flows, consider one of:
Persist flag across authentication steps (tradeoffs: requires
person_profiles: 'always', incompatible with local evaluation and bootstrapping, adds slight latency)Device-ID bucketing — appropriate for landing/marketing/anonymous flows. Keeps the variant stable across the anonymous→identified transition without the flag-persistence tradeoffs. Many users don't realize this option exists; surface it explicitly when the symptom is cross-auth bucketing. Two limits. An evaluation without a device id gets no variant (the flag returns
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.
For pre-auth experiments, ensure cookies/localStorage persistence is configured (cookies preferred for cross-subdomain).
For mobile flows, consider evaluating the flag server-side (local evaluation) once the user is authenticated rather than on first app open.
A4 — Bootstrap × /flags variant disagreement [MEDIUM]
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.
Different identifiers. The server bootstraps flags using the server-known
distinct_id, but the bootstrap payload doesn't includedistinctID— soposthog-jsinitializes with whatever's in persistence (often the anonymous ID). The bootstrap value gets reported under the anonymous ID; thenposthog-jsasks the flag service with whatever ID it has afteridentify(). 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 atidentify(), so this shape needs different device ids.Different inputs
[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 orfalse. The user sees the new experience for a moment and then loses it.
Evidence. The exposure event carries two source properties:
$used_bootstrap_value—truewhen 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_responseholds what the bootstrap payload said for the flag, and is null without one.locally_evaluated—truewhen 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:
Pass
distinctIDin the bootstrap payload when the server already knows the identity (e.g. logged-in users).Bootstrap should be used together with server-side local evaluation, not alone.
Pass person properties to the evaluation in the type the release conditions compare against.
A5 — Flag/experiment state inconsistency [HIGH]
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:
When another experiment on the flag is running, or launched after this one ended, the flag's state belongs to that experiment, and the finding is absent. Do not advise disabling the flag from this experiment, even when the banner says so. Disabling it stops a running experiment.
A draft experiment on the flag, no recorded call of the flag in the last 3 days, or calls that PostHog can't read yet make the finding info, not a warning. A page that shows the health findings shows no banner then.
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.
<!-- Source for maintainers (may rot): ExperimentWarningBanners.tsx in frontend/src/scenes/experiments/ExperimentView, experimentWarning in experimentLogic.tsx, isSingleVariantShipped in products/experiments/frontend/scenes/experimentsLogic.ts. -->
What the banners do not cover:
A pinned variant. A release condition that forces one variant shows no banner (A7b).
A shipped variant. A ship ends the experiment, and the single-variant banner checks running experiments only (E7 in
mid-run-changes.md).Frozen exposure.
statusisexposure_frozen, and no banner appears (E16 inmid-run-changes.md).A deleted flag.
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.
A6 — Mid-run flag edits that rebucket already-exposed users [MEDIUM]
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 |
<!-- Source for maintainers (may rot): get_hash, hashed_identifier, check_rollout and get_matching_variant in rust/feature-flags/src/flags/flag_matching.rs; select_variant in rust/feature-flags/src/flags/v1_bucketing.rs; the variant-key guard in FeatureFlagSerializer, products/feature_flags/backend/api/feature_flag.py. -->
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).
A7 — Non-randomized assignment via release conditions [MEDIUM]
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:
Release conditions are evaluated in their stored order, and the first match wins. A pinned condition forces only the people who matched no earlier condition.
A
variantthat is not one of the flag's variant keys is ignored, and the person is bucketed normally.A holdout is evaluated before every release condition. People in the holdout are not pinned.
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.
A7b — A forced-variant group starves the other arm [HIGH]
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:
Unconditional catch-all forcing one variant. A release group with empty
properties[](matches everyone) and a pinnedvariantatrollout_percentage: 100. Every user who doesn't match an earlier, narrower group falls through to it and is forced to that variant; the randomizedmultivariatesplit never applies. One arm is orders of magnitude larger than the other, and the small arm holds leftovers from earlier flag versions."All new users" cohort forcing one variant, with no control path. A release group like
created_at_unix >= <ts>→variant: testat 100%, where no release group leavesvariant: nulland no group forces the other variant. Every new account is forced totest; thecontrolarm 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:
a group with
variantset and broad/emptypropertiesat highrollout_percentage;no group with
variant: nullor with avariantthat is not one of the flag's variant keys, i.e. nothing is randomized at all;every variant-pinned group forces the same variant — i.e. there is no release path to the other arm.
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:
Ran balanced, then one arm collapses — both arms roughly even for a period, then one variant's new assignments drop toward ~0 from a specific date. The experiment ran as a real A/B and was then rolled out via the flag. The balanced window is the valid result.
One arm never received meaningful traffic — the minority variant is ≈ internal pins / a trickle from the start, never a real share. It was served one variant from the start; it likely never ran as a randomized A/B at all.
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:
Which variant is forced. Forcing
test(the new behaviour) = the new feature was rolled out to everyone. Forcingcontrol(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?").The exposure trend above — ran-then-rolled-out vs never-randomized.
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).
A8 — Migrating the distinct_id strategy during a running experiment [HIGH]
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:
Finish or end the running experiment before the identifier migration, then start a fresh experiment under the new strategy.
If they have to migrate during the run, expect inflated
$multipleand treat the affected window as contaminated — usereset+ relaunch once the migration is complete.An "experience continuity" / flag-persistence approach can paper over anonymous → identified transitions but is not a general substitute for the migration above (see A3 tradeoffs).
A9 — Exposure events are lost or sent unevenly between the arms [MEDIUM]
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:
One arm navigates away. The variant redirects or reloads the page right after the flag is read, and the browser leaves before the SDK sends its queue.
The exposure sits behind the divergence. The event is sent on a page, in a component or after a step that one arm reaches more often, later or never.
One code path reads the flag early. The code reads the flag before the SDK has loaded the flags and falls back to a hardcoded variant, or it reads the flag through a call that sends no exposure (B1 in
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.One arm is slower or fails more often, so people leave before the event is sent.
A cached page carries the assignment
[LOW]. A page that is cached with a variant or an identifier inside hands one assignment to many browsers.The exposure is recorded before anything is rendered. A server or an edge function records the exposure, and a share of those requests never renders a page: prefetches, bots, blocked browsers. The split of exposures is even, and the arms still differ in who arrives. The sample ratio test does not see this shape.
Evidence to gather.
Where the gap sits. Exposures per variant by day and by library (query in
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.The exposure criteria first. A filter in
exposure_criteria.exposure_config.propertiesthat only one arm's events can match gives the same picture, and is a setting, not code (B12 inempty-experiment.md).New against returning people. A gap among returning people only has two candidates: this entry, and the SDK that does not send the event again for a person who already sent it (B3 in
empty-experiment.md). No tool splits exposures this way: addperson.created_at < toDateTime('<start_date>', 'UTC') AS returningto the SELECT and the GROUP BY of the "By day and library" query indiagnostic-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.With a custom exposure event: the split of the flag calls against the split of the custom event. Even flag calls and an uneven custom event put the loss between the flag read and the exposure.
The code around the flag read in each arm: a redirect, a reload, a fallback value, a different component. This is a question for the user when the repository is not at hand.
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.
A custom exposure event at the common entry, for example the page view of the landing URL. It needs the variant in
$feature/<flag-key>(B4 inempty-experiment.md).An exposure from the server at assignment, when the arms diverge in the browser.
Wait for flags to load before the first read, and send no fallback variant.
The results collected so far carry the bias. After the fix, reset and relaunch, or move the start of the analysis to the fix.
A10 — Part of the audience gets no variant from the flag [MEDIUM]
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:
The flag's inputs are not there yet. A release condition reads a person property that the backend sets (a sign-up date, a plan, an app version). A client that evaluates before the property is stored cannot match. New users in onboarding are hit most. With
person_profiles: 'identified_only', anonymous visitors have no stored person properties: the flag sees only what the client sends with the call.Client and server hold different values (A4, second shape).
Device bucketing without a device id. A flag that buckets on the device id returns
falsefor 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.A group-aggregated flag without the group. A flag that is aggregated by a group type returns
falsefor a call that carries no key for that group type.The flag is read before flags load, or before a consent step lets the SDK load them. In posthog-js the read returns nothing only when no flags are cached from an earlier page load: a returning visitor gets the cached value.
The application's own fallback
[MEDIUM]. Code that reports "no variant" ascontrolputs 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 staysfalseand is no exposure.A holdout. People in a holdout get
holdout-<id>in place of a variant. That is intended: they are outside the experiment.
Evidence to gather.
The responses of
$feature_flag_calledby day and by library ("By day and library" indiagnostic-snapshot.md, with$feature_flag_calledas the event): the share that is no variant key, and whether it sits in one library.$experiment_exposureholds nofalseor 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.A
falseshare 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:
Send the exposure from the place that knows the variant the user got.
Put the variant on the exposure event explicitly.
Make the flag's inputs available before the evaluation: pass the person properties with the call, and wait for flags to load.
Avoid release conditions on data that does not exist at evaluation time.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/omnibus/diagnosing-experiment-health · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog or PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download diagnosing-experiment-health.zip