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://mp-staging.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/mid-run-changes.md
Surprises after mid-run changes (incl. lifecycle and retention quirks)
Anything that changed after the experiment was launched, plus the retention-metric and long-term quirks that produce unexpected counts even without an explicit change.
Find the change first. experiment-activity lists what changed on the experiment and on its flag, with the values before and after and the time (see "Changes after launch" in diagnostic-snapshot.md).
The flag's changes are in that list only when the caller has access to the flag: a list without any flag entry is no evidence that the flag did not change.
Most entries below start from one row of that list.
An interruption can bias the result. Pausing, freezing, unfreezing and resuming all change who gets the variant, or when, while the analysis keeps treating the run as one continuous window. Say so whenever the history shows one: E9 for pause and resume, E16 for freeze and unfreeze.
Contents
E1 — Increasing rollout (safe)
E2 — Decreasing rollout (caution)
E3 — Changing the variant split (anti-pattern)
E4 — Adding/removing variants (blocked, but historical traces)
E5 — Changing exposure criteria mid-run
E6 — Adding metrics mid-run (p-hacking)
E7 — Shipping a variant rewrites the flag (ending alone does not)
E8 — Reset clears results, not the flag
E9 — Pause forces control on existing test users
E10 — Retention metric: start event must occur after exposure
E11 — "Matured users" filtering
E12 — Long-term vs short-term metric divergence
E13 — Editability locks (legacy experiments, ended experiments)
E14 — Cleaning up the flag after the experiment
E15 — Restarting an experiment with new variants
E16 — Freezing and unfreezing exposure
E17 — The start date was changed after launch
E1 — Increasing rollout (safe) [HIGH]
No users switch variants; new users are added cleanly. Generally the only change safe to make on a running experiment.
This is the rollout percentage of an existing release condition.
Adding a release condition, or one that pins a variant, is a different edit (A6 and A7 in bias-and-skew.md).
One exception: the first release condition that matches a person decides.
When a later release condition pins a variant, raising an earlier one moves the people it now takes in from the pinned variant to the hashed one.
A rollout that was raised after a launch at a small internal share leaves the early people in the results. When those are internal users, reset the analysis after the raise.
E2 — Decreasing rollout (caution) [MEDIUM]
Users currently in a test variant who fall outside the new rollout will switch back to the default
experience (if they stay active of course). This is a visible UX disruption — the feature they had disappears.
The flag returns false for them, which is no variant, so they do not count as exposed to a second variant.
This holds when no later release condition matches them.
The flag tries its release conditions in order, so a later condition that matches serves them a variant again: the same hashed one, or the one that condition pins.
With early_exit on in the flag's filters, a person outside the rollout of a matching condition gets false at once.
Their data also becomes harder to interpret statistically. Their prior exposures stay counted against the test variant in the analysis. The numerator and denominator already include them. Reducing rollout doesn't retroactively un-bucket; it only stops new exposures and flips re-evaluations. The metric reading after a rollback mixes "pre-rollback test behavior" with "post-rollback default behavior" for the same users — which is what makes it harder to interpret, not a loss of data.
Recommend: if the user wants to reduce rollout to contain blast radius on a problem variant, stop serving the variant in a way that also closes the analysis. Ending the experiment alone does not do that: it fixes the results at the end date and leaves the flag serving every variant. Ship the control variant (E7). A pause (E9) stops serving every variant while the team decides. It does not close the analysis. If they genuinely want to shrink exposure while keeping the experiment alive, treat metric readings from the rollback window onward as mixed and discount them when drawing conclusions.
E3 — Changing the variant split (anti-pattern) [HIGH]
Moves bucket boundaries; users may be reassigned between variants. Reassigned users who come back and send an exposure under the new variant become $multiple users, who then
get excluded (default) or attributed to first-seen. Either way, introduces bias.
The product allows the edit on a running experiment, with a warning only.
After a split change the sample ratio test compares the whole run against the new split, and can report a mismatch that the assignment never had (A2 in bias-and-skew.md), depending on how far the counts of the run sit from the new split.
Recommend: reset the experiment if early; end and start a new one if significant data exists. Where the data before the change is not worth keeping, moving the start date to the change is the smaller step (E17). No tool changes the start date: the user changes it on the experiment page.
Not an anti-pattern: the documented rollout.
A filter of the form timestamp < '<cutoff>' in the exposure criteria closes the analysis to people exposed after the cutoff.
With that filter in place, a later change of the flag to roll a variant out does not touch the analyzed population.
Its metric events still count until the end date, or until each person's conversion window closes, and after the rollout part of that population gets the rolled-out variant.
Results that run past the rollout mix in the rolled-out experience: check the end date and the conversion window.
Read exposure_criteria.exposure_config.properties before calling a split change contamination.
Related shape — the flag's split at launch isn't what the user thinks. When a user reports "one variant has no traffic at all" or "the split doesn't match what I configured", the cause is sometimes not a mid-run change but a pre-launch edit that wasn't visible from the experiment view.
Verify directly. The flag's history in experiment-activity (or feature-flags-activity-retrieve { id: <feature_flag_id> }) returns the
edit history with diffs, newest first and one page at a time (limit, page).
Scan the changes of filters and
read multivariate.variants in the after of the last such change before start_date.
That value is the split the
experiment actually launched with.
Without a change before start_date, the flag launched with the split it was created with: the entry that created the flag carries no values, so read the before of the first filters change after start_date, or the current flag when filters never changed.
If the launch split doesn't match feature_flag.filters.multivariate.variants as the
user described setting it, the launch state itself is the cause — no mid-run change is needed to
explain the missing-variant data.
Fix path: same as E3 generally — reset + relaunch on a young experiment with little data; end + relaunch on one with significant accumulated data. Set the flag's variants to the intended split before clicking launch on the relaunch.
E4 — Adding/removing variants (blocked, but historical traces) [HIGH]
What is blocked on a launched experiment:
The experiment itself rejects a change of the number or the keys of its variants.
The flag rejects removing or renaming a variant key while a linked experiment runs.
What is not blocked: adding a variant on the flag, and changing the percentages (E3).
If a variant was added or removed that way, or before the block existed, expect $multiple exposures
in the data from people who came back after the change.
The analysis reads the variant keys from the flag as it is now: exposures of a removed key leave the results, and a person seen under a removed key and a remaining one counts under the remaining one.
Dropping an arm from the analysis.
An experiment can list excluded_variants.
Those arms are still served, and they are left out of the results and of the sample ratio test.
It is the supported way to stop analyzing an arm without touching the flag.
Recommend: treat the post-change window as contaminated. Reset (E8) and relaunch if the contamination dominates the run, or end + start a new experiment with a fresh flag (E15) if significant clean data exists from before the change.
E5 — Changing exposure criteria mid-run [HIGH]
Edits to exposure criteria after launch can produce surprises — exposure event swap, multivariate handling change, a new filter, or test-account filter toggle all change which events count. Two specific cases:
Switching
multiple_variant_handlingfromexclude→first_seenmid-run is the low-disruption way to mitigate uneven-split exclusion bias on already-collected data. No users switch variants; all data stays.Other exposure-criteria changes re-process historical exposures under the new criteria, which can shift numbers without any actual change in user behavior. Communicate this to the user before they panic. A new filter that nothing can match empties the experiment (B12 in
empty-experiment.md).
If the user is also changing how distinct_id is sent (e.g. anonymous → identified, email → user
ID), that's a different shape — see bias-and-skew.md A8. Identifier migration mid-run re-buckets
users; exposure-criteria edits don't.
E6 — Adding metrics mid-run (p-hacking) [MEDIUM]
Choosing what to measure after seeing data biases your results. Each additional metric is another
result to interpret, and with no multiple-comparisons correction (see interpretation.md), the chance
of some metric looking significant by chance grows.
If the user is hunting for a significant metric after the fact, that's p-hacking — not a real result. Swapping the primary metric after reading results is the same thing.
Note: retroactive metric addition is technically supported (the metric is calculated for the full experiment duration), but using it to fish for significance is a methodology problem, not a tool limitation.
An edit of a shared metric changes that metric in every experiment that uses it, finished ones included (D13 in numbers-vs-sql.md).
E7 — Shipping a variant rewrites the flag (ending alone does not) [HIGH]
Ending an experiment sets its end date and leaves the flag untouched: every variant keeps being served, and exposure events keep arriving.
Shipping a variant rewrites the linked feature flag's variant distribution: the chosen variant gets 100% of the variant distribution, every other variant goes to 0%. The flow has two release modes — pick carefully:
Roll out to the experiment population (default, recommended). Existing release conditions on the flag are preserved untouched. The chosen variant is served only to users who already match those conditions, and per-user variant overrides continue to apply. No catch-all release condition is added. A release condition with a rollout below 100% keeps its rollout: the rest of its audience gets no variant, as before. "We shipped the variant and some users still don't see it" is this. A release condition that pins another variant, and a holdout, keep their people off the shipped variant too.
Roll out to all users (explicit opt-in). In addition to the variant-distribution flip, a catch-all release condition is prepended to the flag's release groups with the literal description "Added automatically when the experiment was ended to keep only one variant." This overrides existing release conditions and bypasses per-user variant overrides — anyone hitting the flag now gets the chosen variant. Holdout people are the exception: the holdout is evaluated before any release condition, so they keep
holdout-<id>. On a flag of an early access feature, the people who opted in or out keep that answer, without a variant, for the same reason.
Both modes flip the active variant ratio to e.g. 0/100. The catch-all release condition is the discriminator between modes. A ship also removes an exposure freeze (E16), and it keeps a holdout in place. A ship never switches the flag on: on a paused experiment the flag stays off after the ship, and nobody gets the shipped variant. The ship also ends the experiment, so Resume is no longer offered: the flag is switched on from its own page.
If the flag distribution suddenly flipped: a ship is the most likely cause. A metric edit and an end without a variant do not touch the flag.
Verify directly. In experiment-activity, a newer ship has an entry variant_shipped on the experiment that names the variant.
An older ship has none: the flag's change of filters that puts one variant at 100% is then the only evidence, and that variant is the shipped one.
That entry sits at the moment of the end when the ship ended the experiment, and later when the variant was shipped after the end.
An end with a variant picked in the End-experiment modal is a ship, whether or not the user thinks of it as one (C10 in interpretation.md).
The flag's entry at the same time shows a change of filters:
A
multivariate.variants[]diff showing the rollout flip (typical signature: 50/50 → 0/100) → E7 is confirmed.Additionally, look at the first release condition in
after.groups[]: emptyproperties, a rollout of 100, and adescriptionof "Added automatically when the experiment was ended to keep only one variant." If present, this was a "roll out to all users" ship and the new release condition overrides the flag's prior targeting and per-user overrides. If absent, this was a "roll out to the experiment population" ship — the variant distribution flipped but targeting is intact. The release groups are unchanged then, except on a flag that still carries an exposure freeze (E16): there both modes also remove the snapshot cohort condition from every group, so only the catch-all tells the modes apart.
<!-- Source for maintainers (may rot): _roll_out_variant in products/feature_flags/backend/facade/api.py (the description is set on the release group itself); ExperimentService.ship_variant and end_experiment in products/experiments/backend/experiment_service.py. -->
The MCP tool that performs this rewrite is experiment-ship-variant. It takes
release_to_everyone: bool (defaults to false = "roll out to the experiment population"); the
agent should confirm the release mode with the user before invoking, in addition to the variant key.
Default to control on ambiguous ships. If the user is unsure which variant to ship — primary unclear, secondaries mixed, or they're still investigating — recommend shipping control. Accidentally rolling out control is a no-op; accidentally rolling out a test variant flips the variant distribution to a not-validated change. If the user also picks "roll out to all users", the blast radius extends past the experiment's existing population — discourage this combination when the user sounds uncertain.
E8 — Reset clears results, not the flag [HIGH]
Reset ("Reset analysis" in the app) returns the experiment to draft and clears start_date, end_date, the conclusion with its comment, and archived.
Events already captured still exist but won't be applied to the experiment unless start_date is
set appropriately after relaunching. The feature flag is left untouched — users continue seeing their
assigned variants during the reset window.
One exception: an exposure freeze is removed, so the flag serves its original release conditions again (E16).
A flag that is off at the reset (a paused experiment) stays off and serves nobody until the relaunch switches it on.
After a relaunch on the same flag, people keep the variant they had.
Those who come back may send no new exposure event [MEDIUM]: the SDKs deduplicate it, and their response has not changed (B3 in empty-experiment.md).
Use case: suspected bias in the existing data, and the user wants to start a clean comparison. Reset + adjust + relaunch is the right path.
E9 — Pause forces control on existing test users [HIGH]
Pause sets the flag's active=false. The flag stops returning a variant, so users fall
back to the application default — typically control. Test users effectively switch back to control
during the pause window. No new exposures are counted while paused.
status reads paused.
Resume reactivates the flag.
People get the same variant as before the pause, unless the flag's variants or release conditions were edited during the pause (the flag's entries in experiment-activity).
A frozen experiment resumes frozen, and status reads exposure_frozen again.
Implication: the pause biases the result [MEDIUM].
The analysis has no notion of a pause.
Metric events during the pause keep counting toward the variant of each person's first exposure, although nobody received the test experience in that window.
With a conversion window on the metric, this reaches only the people whose window overlaps the pause.
The test arm's result is diluted toward control by the length of the pause, relative to the run.
Nothing in the product warns about this.
Evidence. The pause and resume times in experiment-activity: the entries of the experiment, and the changes of the flag's active field.
An older pause has no entry of the experiment: its only trace is the flag's change, which is missing when the caller has no access to the flag.
Recommend: when interpreting results that span a pause window, state the pause dates and explain that the metric data during that window mixes test-variant users with control-like behavior. If the pause was long relative to the run, consider reset + relaunch over interpreting the contaminated data.
E10 — Retention metric: start event must occur after exposure [HIGH]
A retention metric has one of two starts.
Read start_event of the metric first.
A custom start event (the default for a new metric). The start event must occur at or after the user's first exposure, and within the conversion window after it when the metric has one. This is the same design as all other metric types — the analysis question is "what is the effect of this feature after a user sees it?"
start_handling (FIRST_SEEN vs LAST_SEEN) does not relax this. It only picks which
post-exposure start event anchors the retention window when a user has multiple.
Pre-exposure start events are dropped before the choice is made.
Users whose only start events are pre-exposure are excluded
entirely — they don't appear in the retention denominator.
The experiment's exposure as the start (start_event of kind ExperimentExposureNode, "Experiment exposure" in the editor).
Retention counts from each person's first exposure, and every exposed person is in the denominator.
The exception is only_count_matured_users (E11): people whose retention window has not ended since their first exposure are left out.
start_handling and a conversion window do not apply.
In both, the completion must be another event than the one that started the window, and retention windows in days or hours compare calendar days or hours. The editor offers only those two units. A window in another unit, set through the API, compares exact timestamps.
<!-- Source for maintainers (may rot): build_retention_query, build_start_after_exposure_predicate and build_start_event_timestamp_expr in products/experiments/backend/hogql_queries/experiment_retention_query_builder.py. -->
An alternate question — "does this feature change the standard pre-anchored retention metric?", where the start event can be before exposure — isn't supported on experiments. The workaround is to track that metric separately in product analytics.
If retention undercounts unexpectedly: with a custom start event, confirm that the start event has post-exposure occurrences for the affected users. When the user wants every exposed person in the denominator, the exposure start is the fix.
If the number does not fit the name: compare the metric's definition with what the user means by retention (D13 in numbers-vs-sql.md).
E11 — "Matured users" filtering [HIGH]
An experiment-wide setting, only_count_matured_users, shown as "Require completed conversion or retention window".
With it on, each metric that has a conversion window leaves out the people whose window has not elapsed since their first exposure.
A retention metric uses the end of its retention window instead, counted from each person's start event, or from the first exposure when the exposure is the start (E10).
Metrics without a window are not affected.
A project default decides its value for new experiments.
Implication: turning this on reduces the user count in the analysis (recent users excluded) but makes per-user metric values more comparable across cohorts. If the user count drops unexpectedly, or differs between metrics of one experiment, check this setting and each metric's window.
E12 — Long-term vs short-term metric divergence [MEDIUM]
Primary (short-term) and secondary (long-term) metrics moving in different directions is normal — a checkout-flow change might lift conversion now but hurt retention later.
Recommend:
Keep the short-term metric as primary and long-term as secondary — don't promote long-term to primary just because it disagrees.
To keep measuring the people already enrolled while no new ones enter, freeze exposure (E16).
Use a holdout for sustained measurement across experiments. A holdout is a share of people, drawn by hash at every flag evaluation, who get
holdout-<id>in place of a variant and are outside the experiment's results. Compare them with everyone else in an insight broken down by the flag's value.For deeper segment analysis, add a breakdown to the metric (D3 in
numbers-vs-sql.md), or use session replays to see what behavior differs between variants.
Holdouts under local evaluation [MEDIUM].
A server SDK that evaluates flags locally and predates holdout support puts holdout people into the regular variants.
Evidence: the share of holdout-<id> responses is below the configured share, and the gap sits in one library (by-library query in diagnostic-snapshot.md).
Fix: update the SDK.
E13 — Editability locks (legacy experiments, ended experiments) [HIGH]
Legacy experiments (
is_legacy: trueinexperiment-get) — only the name, the description and the end date can be edited. Results are no longer calculated. The UI shows a warning banner with a "Migrate experiment" button.Launched experiments — the variant keys can't be edited (E4). If edits are needed, duplicate (E15), or reset (E8) and re-launch.
If the user is fighting an editability lock, that's a sign the experiment should be migrated, duplicated or reset rather than worked around.
What makes an experiment legacy.
is_legacyis the one field to read.Behind it: a metric of kind
ExperimentFunnelsQueryorExperimentTrendsQuery(notExperimentMetric), inline or through a shared metric.
A legacy experiment's results are not readable through experiment-results-get.
Don't confuse this with a data: null row on a non-legacy experiment — that is a query that failed in one call.
See "Metric rows with data: null" in diagnostic-snapshot.md.
Fix path: migrate the experiment with experiment-migrate.
It creates a new experiment with the same configuration and the metrics converted, on the same flag, so no new rollout is needed and people keep their variant.
The legacy experiment stays as it is.
Tell the user about the second experiment before migrating.
experiment-duplicate rejects a legacy experiment.
Alternatively, end the existing experiment with a documented conclusion if the original
hypothesis is no longer interesting.
E14 — Cleaning up the flag after the experiment [HIGH]
What the link between an experiment and its flag allows:
Deleting the flag is blocked only while the experiment runs. A draft, stopped or archived experiment does not block it, and the experiment keeps its results.
A deleted flag frees its key. Restoring it later reclaims the key, or gets a numeric suffix (
my-flag-2) when another flag took the key meanwhile. The application code then reads a key that is no longer this flag.The flag cannot be unlinked from the experiment.
Simplifying the flag
[MEDIUM]. Once the experiment has ended, the flag no longer protects its variant keys, so the variants can be removed on the flag itself. The ended experiment reads its variant keys from the flag: a removed variant leaves its results at the next calculation. On the flag's overview the edit control of the variants stays disabled while an experiment is linked. The flag's edit form is the way in.Archiving needs an ended experiment.
Results that seem to be gone.
Deleting an experiment hides its results. Deleting its flag does not: the experiment keeps its results.
Neither deletes an event.
A deleted flag can be restored from its page.
A deleted experiment can be restored too: the delete confirmation offers an undo, and an API update that sets deleted to false restores it, once its flag is restored.
The app has no list of deleted experiments, and experiment-update does not take deleted.
Recommend: before cleaning up, confirm the flag's future use. If the user expects to keep using the flag for general rollout after the experiment ends, ship the variant (E7) — that leaves the flag in a usable state at the chosen rollout. If they're done with the flag too, keep the flag until the calling code has been removed.
E15 — Restarting an experiment with new variants [MEDIUM]
The clean approach is:
End the existing experiment.
Duplicate it (
experiment-duplicate), or create a new one. The duplicate is a new draft with the metrics and settings of the original, without its holdout and its excluded variants.Give it a new feature flag key. A duplicate without a new key shares the original's flag, so a ship or a pause on one affects both. The
flag_sharedhealth finding reports a shared flag: a warning on a running experiment when another running experiment uses its flag, and on a draft whose flag a running experiment uses, and info on a running experiment whose flag a draft uses. It doesn't count ended experiments, because each experiment reads exposures only between its own start and end.Launch the new experiment under the new flag.
Why a new key and not a reset on the old flag:
The same key gives every returning person the same draw as before, so the new run is not a fresh randomization for them.
Returning people who already sent the exposure event may not send it again (B3 in
empty-experiment.md).A changed set of variants on the old flag moves people between variants (E3).
The same three points explain a follow-up run that disagrees with the first one on the same flag: it is not an independent test.
Reusing the same flag with new variants on a new experiment is technically possible but tends to produce confusing exposure histories. Only do this if the user is explicit about wanting to keep historical bucketing comparable.
E16 — Freezing and unfreezing exposure [HIGH]
Symptom (in the user's vocabulary): "exposures stopped growing and the experiment still says running", "after the freeze some users lost the variant", "can I freeze this experiment", "the freeze was refused".
Behavior. Freezing stops enrollment and keeps measuring.
PostHog saves the people exposed so far as a static cohort and narrows every release condition of the flag to that cohort.
The variant split and the end date stay untouched. status reads exposure_frozen, and no banner appears.
New people get no variant.
Enrolled people keep their variant, and their metric events keep counting.
The cumulative exposure series goes flat. That is the intended effect.
Where a freeze biases or breaks the run:
People exposed just before the freeze can miss the cohort. They count as exposed, and from the freeze on they get no variant.
Local evaluation. A static cohort cannot be evaluated locally. A server SDK falls back to a remote call, and one that is set to evaluate only locally serves the flag's default to everyone, enrolled people included
[MEDIUM].Release conditions edited or added after the freeze. A variant override set later on a frozen release condition keeps that condition's cohort, so people outside the cohort still get no variant from it. An override set on a new release condition carries no cohort: that is the next case. A release condition added without the cohort reopens enrollment, and
statusreadsrunningagain.Ending without shipping leaves the flag narrowed to the cohort. Shipping a variant or resetting removes the freeze.
Unfreezing reopens enrollment. People who enroll after it joined later and possibly under other conditions than the first group. Mixing both groups in one analysis can bias the result. The unfreeze dialog is the one place where the product itself warns about bias. The End dialog of a frozen experiment warns only that the flag stays narrowed to the cohort.
When a freeze is refused: a draft, an ended or a paused experiment; an experiment that is already frozen; a missing or deleted flag, or a flag without release conditions; a group-aggregated experiment; an experiment with a holdout or with early-access conditions in the older format (super_groups in the flag's filters); too many exposed people to save in one step; more than a small share of exposed people without a person profile, which rules out experiments on logged-out traffic; no exposures yet.
<!-- Source for maintainers (may rot): freeze_exposure and unfreeze_exposure in products/experiments/backend/experiment_service.py; freeze_exposure_blocker and status_label in products/experiments/backend/models/experiment.py. Docs: https://posthog.com/docs/experiments/managing-lifecycle#freezing-exposure -->
Evidence to gather. status from experiment-get.
Every freeze and unfreeze in experiment-activity, with its time: a change of the flag's release conditions that adds the cohort to every condition, or removes it.
Newer ones also have a named entry on the experiment (exposure_frozen, exposure_unfrozen).
Older ones have only the flag's change, which is missing when the caller has no access to the flag.
Any other change of the flag's release conditions after the freeze.
Whether the user's servers evaluate flags locally.
Co-occurs with: E9, E12, and the plateau check in empty-experiment.md.
Recommendation.
A plateau on a frozen experiment needs no fix.
State the freeze and unfreeze dates with any result that spans them.
Before an unfreeze, say that the two enrollment groups will be mixed. A user who only wanted to check the frozen results does not need to unfreeze.
For a flag with many release conditions or overrides, freezing is fragile: every later edit of the flag has to keep the cohort condition.
E17 — The start date was changed after launch [MEDIUM]
Symptom (in the user's vocabulary): "the numbers changed overnight and nobody touched the experiment", "people from launch day disappeared", "the experiment and the insight disagree since last week".
Behavior. The experiment analyzes exposures from start_date on.
An edit of the start date moves that boundary for the whole run:
Moved later: people whose exposures all lie before the new start leave the experiment. A person exposed before and after counts from the first exposure inside the window, and their earlier metric events no longer count.
Moved earlier: exposures from before the launch enter: internal testing, another split, another audience.
An insight on the same events ignores the experiment's start date, so the two disagree after such an edit.
Evidence to gather. Changes of start_date in experiment-activity, each with its old and new value.
A start date before the flag had its current split or audience (the flag's entries in the same history).
Co-occurs with: E3, E8.
Recommendation. A start date moved to just after a fix is a legitimate way to cut a contaminated beginning: say what was cut. The user makes this edit on the experiment page; no tool changes the start date. A start date moved back to recover data brings in whatever the flag did then: check the flag's history for that period before trusting the result. Repeated edits of the start date are a reason to reset and relaunch.
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.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download diagnosing-experiment-health.zip