Generated banner for Diagnosing Experiment Health
Generated logo for Diagnosing Experiment Health

Diagnosing Experiment Health

PostHogOfficialPublished by PostHog

  • Skill
  • Review and audit
  • Data and AI
  • MIT
  • No issues found

From the author

Runs diagnostics and a health check on one PostHog experiment: its setup, exposures, results and changes during the run. Covers 0 exposures, sample ratio mismatch, lost or uneven exposures, users in multiple variants, identity faults, who the experiment counts, significance traps (peeking, A/A, 'target reached', Bayesian vs Frequentist, sequential, CUPED), mid-run edits, pause and freeze, and survey follow-up. TRIGGER when: user asks 'is my experiment healthy / set up right / biased?' or 'why 0 exposures?', asks why it counts different people than their insight, mentions the bias or mismatch warning, an uneven split, significance that flips, numbers that disagree with their SQL, results that changed after an edit, or wants user feedback on an experiment. DO NOT TRIGGER when: creating an experiment (use creating-experiments), only configuring rollout (use configuring-experiment-rollout) or metrics (use configuring-experiment-analytics), or only asking lifecycle questions (use managing-experiment-lifecycle).
— the description by PostHog

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

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/posthog/diagnosing-experiment-health/download
  2. Unzip 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_legacy

  • feature_flag.active, and feature_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 person

  • feature_flag.filters.groups[] — for each group read variant, properties, and rollout_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-null variant that 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/empty properties at 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). See references/bias-and-skew.md.

  • feature_flag.bucketing_identifier and feature_flag.ensure_experience_continuity — what the flag hashes (A3, A6, A8, A10)

  • holdout and excluded_variants — people and arms that are outside the analysis on purpose

  • exposure_criteria.multiple_variant_handling — defaults to "exclude" if absent

  • exposure_criteria.exposure_config — the exposure event and its property filters. Unset, or naming $feature_flag_called, means the default exposure event: read which one from resolved_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_exposure counts that event, whatever resolved_exposure_event says. Any other event, or an action, is a custom exposure event. Property filters in the config apply to either (B12). The table in references/diagnostic-snapshot.md says 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 to true

  • metrics and metrics_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 missing method reads 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 biasA — 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 appA — bias & skew
Banner about feature-flag/experiment state mismatchA — bias & skew
"Migrating distinct_id" / "switching from anonymous to user_id" mid-runA — 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" / emptyB — 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 editedE — 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 experimentF — 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 against running_time_calculation.recommended_sample_size) before reading a result (C1 in references/interpretation.md). The page takes the exposed total from the first primary metric: the sum of its number_of_samples over all variants in the stored results, not exposures.total_exposures. recommended_sample_size is 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_criteria and the flag's release conditions) with the people the decision is about before calling the setup sound (D12 in references/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.active true, and an edit of that flag reaches those users at once.

  • Running (and paused or exposure_frozen, which are launched and not ended) — every change has a tradeoff. Explain the mid-run impact (anti-pattern? safe? user-visible?) before recommending. See configuring-experiment-rollout and its reference file references/changing-distribution-after-launch.md for 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 in references/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.

  • configuring-experiment-analytics — fix the exposure or metric configuration the diagnosis points at

  • configuring-experiment-rollout — split-change anti-patterns and safe rollout adjustments

  • managing-experiment-lifecycle — reset, end, or restart when the experiment can't be salvaged in place

  • analyzing-experiment-session-replays — when the numbers are fine but you need to see the behavior behind them

  • debugging-experiments — when the question arrives as a support request about someone else's experiment and the answer is a written reply

Files

references/interpretation.md

Significance & interpretation traps

How to read PostHog experiment results without falling into common interpretation pitfalls.

Read the statistics settings before any result (Step 1 of SKILL.md): the method, the confidence level, sequential testing, CUPED and the baseline variant. C5 to C7 cover the method and the confidence level. C13 covers sequential testing, CUPED and the baseline variant.

Contents

  • C1 — Peeking / early stopping

  • C2 — Low-volume variance (looks broken but isn't)

  • C3 — A/A test showing significance

  • C4 — Multiple comparisons (no correction across variants or metrics)

  • C5 — Bayesian interpretation traps

  • C6 — Frequentist interpretation traps

  • C7 — Bayesian vs Frequentist confusion (overlapping intervals, p-values)

  • C8 — Inconclusive but trending — when is it ok to ship?

  • C9 — "Significance reached" notification is not a green light to ship

  • C10 — Ship-variant choice does not consider any metric result

  • C11 — External calculator disagrees with PostHog

  • C12 — "Target reached" on the running-time estimate is not a result

  • C13 — Statistics settings that change how a result reads (sequential testing, CUPED, baseline variant)

C1 — Peeking / early stopping [HIGH]

Watching results live and ending the experiment the moment it looks significant inflates false positives — you're giving randomness more chances to look significant.

The exception: sequential testing. A Frequentist experiment with sequential testing on keeps its false-positive rate at the chosen level however often the result is read (C13). The peeking warning does not apply to it. Bayesian experiments have no sequential mode.

Evidence. All of it is in experiment-get and the stored results:

  • Age: start_date against now.

  • Sample: the exposed total across all variants against running_time_calculation.recommended_sample_size. That target is a total for all variants, not a number per variant.

  • Time: running_time_calculation.recommended_running_time. On a launched experiment it is the number of days still missing at the current exposure rate, as of the last time someone opened the experiment, and 0 once the target is reached. It is not the planned length of the run. Three cases hold the whole estimated length instead: an estimate from numbers the user typed (exposure_estimate_config.conversionRateInputType is manual), a value written when the experiment was created that no page load has replaced since, and a value saved by a page load in the first day of the run or before 100 exposures, when no later page load has replaced it (C12). Judge progress by the sample, not by this number.

  • Method: stats_config.method, and stats_config.frequentist.sequential_testing_enabled. A missing method reads as Bayesian. When the experiment does not set sequential_testing_enabled, the project default applies (C13).

  • Floor: a variant with fewer than 50 exposures has no result at all (C2).

Say it unprompted.

MUST state the experiment's age and its share of the target sample before any reading of a result, when the experiment is below its target sample and sequential testing is off. One day into a run, the honest first sentence is that the result is not readable yet. When the experiment has no target, state the age and the exposures per variant, and say that no target was set.

MUST NOT withhold the numbers or refuse the question. Give the current estimate, labeled as early, and say when it becomes readable.

In the early days of the experiment, significance can flip back and forth a lot: the floor of 50 exposures per variant is where analysis starts, not where it becomes stable.

Recommend:

  • Predetermine duration before launching. Use the running-time calculator on the experiment.

  • Or turn on sequential testing before launch, when the team wants to read results continuously.

  • Frequentist: PostHog uses α=0.05 by default → a single metric has ~5% chance of false-positive significance even when nothing changed.

  • Don't treat 0.05 as a hard cliff. It's a convention, not a meaningful threshold by itself — results just below and just above are close to equivalent in evidence.

C2 — Low-volume variance (looks broken but isn't) [MEDIUM]

Symptom: few hundred or fewer exposures per variant; the visible split looks badly off (a roughly 2-to-1 skew at a few dozen exposures can be chance alone: 30 against 16 gives a p-value near 0.04, far from the 0.001 that the product flags).

Mechanism: With low samples per variant (rule of thumb: under a thousand), the visible split can swing widely from the configured ratio — deterministic-hash variance is large at small samples. PostHog's calculations account for this; the visible ratio is not a bug. Whether a split is a real mismatch is the sample ratio test's call, not the eye's (A2 in bias-and-skew.md).

Validity gates [HIGH]. A variant gets no result, and the metric reads "Not enough data yet", until:

  • the variant has at least 50 exposures;

  • for a funnel or retention metric, the variant has at least 5 conversions;

  • for a ratio metric, the denominator is not zero;

  • the baseline variant's total is not zero.

A Bayesian funnel result additionally needs at least 5 people who converted and 5 who did not, in each variant. So a variant where almost everyone converted reports nothing either. The same check applies to a Bayesian mean metric with a threshold (5 people who reached it and 5 who did not), and not to a funnel that CUPED adjusts.

The stored results name the failed gate in validation_failures (table in diagnostic-snapshot.md). Some checks give no result and leave validation_failures empty: the Bayesian 5-and-5 check above, a baseline mean below zero under Bayesian, and a metric whose values do not vary. The variant then has no interval, no significance and no entry that says why. The fix is the same for all: more exposures, or accept that the result isn't ready.

<!-- Source for maintainers (may rot): - validate_variant_result in products/experiments/backend/hogql_queries/utils.py; - _validate_normal_approximation in products/experiments/stats/bayesian/tests.py. -->

Recommend: wait. Run longer or increase rollout. The gates are where analysis starts. The sample the experiment needs is the running-time calculator's target (C12).

C3 — A/A test showing significance [MEDIUM]

A/A tests should rarely show significance. If the user reports their A/A test is showing a significant difference, work through:

  1. Is it random chance? With a correct methodology, a share of metric-variant pairs in an A/A test will look significant by chance. With multiple metrics × multiple variant pairs, expect some to flicker significant. C4 below has the rates. A result read early and often adds to it (C1).

  2. Is the exposure the same for both arms? Two arms that serve the same experience can still differ in how the exposure event is sent or who arrives after it: an exposure recorded on the server before anything is rendered, an exposure event that one path sends more reliably. Even exposure counts do not rule this out (A9 in bias-and-skew.md).

  3. Is it exposure handling? On an uneven split with multiple_variant_handling = "exclude", people in more than one variant leave the smaller arm in a larger share (A1 in bias-and-skew.md). On an even split they leave both arms alike.

  4. Is the implementation correct? A large multiple-fold gap between equal-sized variants is extremely unlikely to be random — instrumentation is the more likely cause. A specific shape worth checking: data-warehouse-source metrics where per-user exposures are joined to a per-group warehouse table (ExperimentDataWarehouseNode with events_join_key: $group_<n> on the exposure side and data_warehouse_join_key on a group-keyed metric table). The join gives each exposed user all rows of that user's group, so a sum metric over-counts proportional to per-group user count. If user counts are balanced but per-group user counts aren't, the sum can swing widely even on a true A/A — and the test reads that as significant. Detect: the metric's definition in experiment-get: a warehouse source whose join key is a group key, on an experiment that is aggregated by person. Sanity check: re-aggregate the warehouse table by org/group once (deduped) and compare to the per-user sum; a large gap confirms repeated-row inflation.

  5. Is it a legacy experiment? A legacy experiment shows no results (E13 in mid-run-changes.md). Migrate it before reading an A/A result.

Recommend: if chance and exposure do not explain the result, investigate instrumentation rather than assuming the methodology is wrong.

C4 — Multiple comparisons (no correction across variants or metrics) [HIGH]

PostHog does not apply multiple-comparisons correction:

  • Across variants — each test variant is compared to the baseline independently

  • Across metrics — each metric is tested independently

So with many metrics or many variants, the chance of some spurious significance grows.

  • Frequentist at α=0.05 (the default): with 5 independent metrics, the chance of at least one false positive is ~23%; with 10 metrics, ~40%.

  • Bayesian: a result is significant when the chance to win is above the confidence level or below one minus it. At the default 95% that is two tails of 5% each, so about one comparison in ten is marked significant when nothing changed, and the chance of at least one among 5 metrics is about 40%.

(Confidence level is configurable — see C6.)

Recommend:

  • Define a small set of planned, hypothesis-driven metrics up front.

  • Treat results as a pattern across planned metrics, not a single "gotcha" significant metric.

  • Add guardrail metrics as secondary, not primary.

  • An unexpected metric that turns significant is a hypothesis for a follow-up experiment with that metric as the primary, not a reason to ship.

  • Be especially wary of metrics added after seeing data — that's p-hacking. See mid-run-changes.md.

C5 — Bayesian interpretation traps [HIGH]

PostHog defaults to Bayesian, unless the project's default method says otherwise (C7). Common misreads:

  • "96% chance to win" is about direction (test is better than the baseline), not the magnitude of the lift. Read the credible interval alongside it.

  • The threshold is the confidence level. A result is significant when the chance to win is above the level, or below one minus it. 96% is significant at 90% and at 95%, and not at 99%. At 95%, a chance to win below 5% is a significant loss.

  • A metric whose goal is a decrease (goal: "decrease" on the metric). The page shows one minus the stored value, so that a high chance to win always means "better". The stored chance_to_win is not inverted: on such a metric a stored value below 5% is a significant win.

  • "Significant, and the interval still crosses zero." Significance uses the chance to win, a one-sided value. The interval shown is two-sided at the same level. At 95%, a chance to win between 95% and 97.5% is significant while the 95% interval still includes zero. Both statements are correct. The result is borderline. The page's own help says otherwise: the tooltip of the interval column and the "How to read" tooltip say that a result is significant when the interval does not cross 0%. That is the Frequentist rule. Under Bayesian, the "Significant" column and the "Won" or "Lost" tag follow the chance to win.

  • Don't ship the moment chance-to-win flips green — early flips are within the noise band (C1).

  • Flat priors. PostHog uses a non-informative prior. Early swings aren't the prior pushing things around — they're the data being sparse.

  • The displayed effect is relative to the baseline: 10% against 12% conversion reads as +20%.

  • Legacy experiments. Legacy experiments (is_legacy in experiment-get) no longer show results. Migrate them with experiment-migrate. See E13 in mid-run-changes.md.

<!-- Source for maintainers (may rot): is_decisive in products/experiments/stats/bayesian/tests.py; credible_interval in products/experiments/stats/bayesian/utils.py. -->

C6 — Frequentist interpretation traps [HIGH]

PostHog has Frequentist support. Set in stats_config.method. Quick rules:

  • The confidence interval doesn't cross 0 → significant vs the baseline. It crosses 0 → not significant.

  • PostHog uses Welch's t-test as the default — it handles unequal variance between groups, unlike Student's t-test (which assumes equal variance). With sequential testing on, a sequential test replaces it (C13).

  • α = 0.05 by default → ~5% chance of false-positive on a single metric.

  • Confidence level is configurable per project (and per experiment). The values offered are 90%, 95% and 99%. The project default is default_experiment_confidence_level. It is copied into a new experiment at creation, as stats_config.frequentist.alpha and stats_config.bayesian.ci_level, so a later change of the default does not move existing experiments. The interval column's title reads "Credible interval (95%)" or "Confidence interval (95%)" at every level, and the "How to read" tooltip says 95% too. The interval itself is computed at the experiment's level. If a user reports a p-value of 0.07 as "significant", they're likely on the 90% setting; check before debugging the math.

  • Significance is per-metric. With many metrics, expect some to flicker in/out as the sample grows.

C7 — Bayesian vs Frequentist confusion (overlapping intervals, p-values) [MEDIUM]

A frequent source of confusion:

  • Overlapping intervals do not imply non-significance, under either method. Two intervals drawn per variant can overlap while the difference between the variants is significant. The page does not draw per-variant intervals: each test variant has one interval, of its difference from the baseline. Under Frequentist, that interval decides: it does not cross 0 exactly when the result is significant (C6). Under Bayesian, significance is determined by win probability, so that interval can still cross 0 on a significant result (C5).

  • p-values don't apply in Bayesian. A question about "p < 0.05" is a frequentist frame. If the experiment is on Bayesian, redirect to win probability + credible interval.

  • The method is a per-experiment setting with a project default. A new experiment takes the project's default method at creation. Read stats_config.method of the experiment. Do not assume Bayesian.

Shipping an inconclusive result can be defensible when all of these hold:

  • A clear primary metric improvement without a guardrail regression

  • Strong qualitative conviction (replays, user feedback, intuition)

  • The cost of being wrong is low (e.g. easy to roll back via the flag)

Do not ship if the timeseries chart shows a sustained regression — point-in-time significance can flip, but a sustained downward trend on the timeseries is a stronger signal than a snapshot reading.

Recommend the user open the experiment's timeseries view (per metric) — point-in-time significance can flip, but a sustained trend is a stronger signal than a snapshot reading. The agent can also pull this directly via experiment-timeseries-results, with the metric's uuid and fingerprint from experiment-get (a shared metric carries both in saved_metrics[].query). In the series, look for a single day that carries the whole effect: one unusual day can make a flat experiment look significant.

For the qualitative part (replays / intuition), invoke the posthog:analyzing-experiment-session-replays skill — it surfaces variant-level replay patterns and is the right tool when the call is "primary metric is up, no guardrail regression, do we ship?"

C9 — "Significance reached" notification is not a green light to ship [HIGH]

PostHog can mark a metric as significant and send a notification well before the experiment has accumulated enough data for the result to be stable. The verdict can revert as the sample grows, and no notification is sent when it does. Treat the notification as a prompt to review, not an instruction to ship.

The notification is an opt-in destination on the experiment's Settings tab. It is sent from the scheduled calculation, each time a variant turns from not significant to significant against the calculation before. A result that reverts and comes back is announced again. Its only gates are the validity gates of C2: no minimum number of days, no check against the planned sample. The scheduled calculation covers only experiments with status running that started in the last 60 days, and no legacy metric: outside that, no notification is sent.

Before acting on a significance notification, check all of:

  • Participants per variant. The gate is 50 exposures per variant, and 5 conversions for a funnel or retention metric. That's a floor for analysis, not a sufficiency bar for shipping. Aim for the target sample of the running-time calculator.

  • Days running. For high-stakes ships, wait at least a full week before acting on a significance flag — shorter windows can swing as the sample grows. This is a working norm, not a product-enforced threshold.

  • Pre-planned duration. If the experiment hasn't reached the length the team planned, the significance is "current best estimate", not "settled". The experiment stores no planned end date: compare the exposed total with the target sample (C1), or ask the user what the team planned. Sequential testing is the exception (C13).

  • Variant balance and $multiple share. If a bias or skew diagnostic (bias-and-skew.md) is in play, the significance verdict is suspect regardless of how large the gap looks.

  • Secondary metrics. See C10.

When a previously-significant result reverts to not-significant, that's not a bug — it's the same analysis updated with more exposures. Explain the difference between signal seen so far and result confirmed.

C10 — Ship-variant choice does not consider any metric result [HIGH]

The End-experiment modal opens with the "Variant to keep" selector empty, so ending the experiment rolls out nothing unless the user picks a variant. Nothing behind that pick reads the results: there is no significance check, no primary-metric direction check, and no guardrail check. The "End experiment" button is gated by selecting a conclusion (won / lost / inconclusive / stopped early / invalid), not by the variant selector. The modal keeps a picked variant while the page stays open, so a user who picked one earlier and comes back ships that variant, unexamined.

The modal also asks how to release the chosen variant, with two radio options:

  • Roll out to the experiment population (default, recommended) — variant distribution flips to 100/0 for the chosen variant; the flag's existing release conditions and per-user variant overrides are preserved. Only users already in the experiment's population see the variant.

  • Roll out to all users — additionally prepends a catch-all release condition that overrides existing release conditions and per-user overrides. Anyone hitting the flag gets the chosen variant.

The release-mode choice doesn't read metrics either; the safer "experiment population" option is the default.

<!-- Source for maintainers (may rot): FinishExperimentModal in frontend/src/scenes/experiments/ExperimentView/ExperimentModals.tsx. Verify before citing. -->

Recommend: before clicking "End experiment", do three things:

  1. Manually review every metric — primary direction and significance, plus every secondary / guardrail metric. The product names no winner for the experiment: the "Won" and "Lost" tags on the page are per metric.

  2. Explicitly choose the variant to keep after reviewing the metrics, or leave the selector empty to end without shipping.

  3. Confirm the release mode matches intent — "experiment population" keeps the variant scoped to current targeting; "all users" overrides existing release conditions and per-user overrides. The default is the safer choice; flag any non-default selection back to the user explicitly.

If any guardrail is trending negative, or the primary isn't actually significant, the safe move is to keep control rather than ship the test variant. This matters most for sophisticated users who set guardrails for a reason.

C11 — External calculator disagrees with PostHog [MEDIUM]

A common case: conversion counts from the experiment page get pasted into an online A/B calculator, or another AI assistant is asked for the numbers, and the answer differs from PostHog's ("not significant" vs "significant", a different count, a different winner).

Three questions to ask before debugging stats:

  1. Which methodology? PostHog is Bayesian by default. Most online calculators are Frequentist. The two answer different questions; they will not agree on borderline cases. If the user wants a Frequentist comparison, change stats_config.method and re-read (see C6).

  2. Are the inputs actually the same? The numbers on the experiment page are post-scope — $multiple excluded, test accounts filtered, exposure-bounded date range, per-user aggregation for means, the conversion window applied. An online calculator gets none of that — if the user typed in raw event counts they grabbed from SQL, the calculator and PostHog are computing on different populations, and disagreement is expected. An assistant that counts a metric's events without the experiment's scope answers a different question too (D1 in numbers-vs-sql.md).

  3. Does the experiment adjust the values? Outlier handling, CUPED and sequential testing change the estimate or the interval (C13). No calculator on raw counts reproduces them.

After confirming both methodology and inputs match, if the disagreement persists, treat it as a real anomaly worth investigating with the experiment URL.

C12 — "Target reached" on the running-time estimate is not a result [MEDIUM]

Symptom (in the user's vocabulary): "the experiment says complete but nothing is significant", "it finished within days and we had planned for weeks", "it reads 'Target reached' while every metric still says 'Not enough data yet'".

Behavior [HIGH]. The duration display next to the experiment's status is an estimate of sample size. It reads "~N days left" while the exposures are below the target, and "Target reached" once they are at or above it. When the target is more than four weeks away, it reads "Can detect X%+ changes within 4 weeks" with a warning icon instead. X is the smallest change that the experiment can detect within four weeks at its current traffic, not the minimum detectable effect. Above 100% it reads "Can detect only changes over 100% within 4 weeks". Older versions read "Complete". On an ended experiment "Complete" is the status tag: every ended experiment carries it, and the duration display is hidden. Read status first when the user says "complete".

  • The target is the sample needed to detect the minimum detectable effect with about 80% power, at the confidence level of the experiment's analysis (95% by default).

  • The estimate uses the first primary metric only, with the variance that the analysis computes from its results (after CUPED, when the experiment uses it).

  • The target is sized so that the comparison of the control with the smallest variant can detect the effect, so an uneven split needs a larger total than an even one.

  • The minimum detectable effect is the project's default, and 30% where none is set. A 30% effect on a conversion rate needs a small sample, so the target is reached within days on a busy surface. A summed value such as revenue varies much more from person to person, so its target can be many times larger.

  • In manual mode the target comes from the baseline the user typed, not from the results.

  • "Target reached" says that an effect of that size would probably have shown. It says nothing about significance, and it does not end the experiment.

  • The running time settings list the exposures and the change that the experiment can detect today, at the next week marks, and on the date it reaches the target. A change over 100% shows as ">100%".

<!-- Source for maintainers (may rot): ExperimentRemainingTime.tsx in products/experiments/frontend/components/ExperimentMetaBar; runningTimeLogic.ts in frontend/src/scenes/experiments/RunningTimeCalculator; running_time_target.py in products/experiments/backend. -->

Evidence to gather. running_time_calculation in experiment-get:

  • recommended_sample_size — the target, a total across all variants.

  • recommended_running_time — the days still missing at the current exposure rate, and 0 once the target is reached. A value far beyond the time the team will run the experiment (many months, years) means that the target is out of reach at this traffic: the experiment cannot detect an effect of that size in a useful time (C8).

  • exposure_estimate_config.conversionRateInputType — manual means that the user typed the baseline and the daily exposures. Both numbers above then come from that input: recommended_running_time is the whole estimated length, not the days left, and neither number is updated during the run. The same holds for numbers that were written when the experiment was created, through the API or by an agent, until someone opens the launched experiment. A recommended_running_time above 0 on an experiment whose exposures have passed the target is one of these cases, or a value saved by a page load before the target was reached.

  • minimum_detectable_effect — the experiment's own value, or the project default that was copied in when the experiment was created. When it is absent the project default applies. experiment-setup-context returns that default (team_defaults) where the tool is available. Otherwise ask the user.

In automatic mode the product estimates the target again from the experiment's own data whenever someone opens the page of a launched experiment, and saves it. So the target moves during the run, and the label a user saw last week can differ from today's stored numbers. Compare the minimum detectable effect with the effect the user hopes for.

Co-occurs with: C1 (the same people read results early), C2.

Recommendation.

  • Explain the label first: a target for sample size, not a verdict.

  • When the minimum detectable effect is far above a realistic effect, the target is too small. Lower it to the smallest effect worth shipping for: the required sample grows with the inverse square, so half the effect needs four times the sample.

  • When the decision metric is not the first primary metric, the estimate is for the wrong metric.

  • The rule of thumb behind the calculator: the sample per variant is about 16 × variance / (absolute effect)². For a conversion rate the variance is p × (1 − p).

C13 — Statistics settings that change how a result reads (sequential testing, CUPED, baseline variant) [HIGH]

Three settings change what the numbers on the page mean. Each is set on the experiment. Sequential testing and CUPED fall back to the project default where the experiment does not set them. The baseline falls back to control.

Sequential testing (stats_config.frequentist.sequential_testing_enabled). Frequentist only.

  • The p-value and the interval stay valid however often the result is read. C1's peeking warning does not apply.

  • The price is a wider interval than a fixed-horizon test gives on the same data. "The calculator says significant and PostHog does not" is expected (C11).

  • The interval narrows as the sample grows. Its extra width over a fixed-horizon interval is smallest when the two arms of a comparison together hold about the tuning parameter's sample size (sequential_tuning_parameter, default 5,000 or the project's default), and larger far from it. An experiment with a much larger sample and the default value has a wider interval than it needs.

  • A p-value of exactly 1.000 is normal [MEDIUM]: the value stays at 1 until the evidence crosses a threshold.

CUPED (stats_config.cuped.enabled).

  • It adjusts the metric values with each person's behavior before exposure, and narrows the interval when that behavior predicts the metric.

  • It does not change who is in the experiment or which events count.

  • The effect and the interval come from adjusted values, so no query on raw events and no calculator reproduces them (D1 in numbers-vs-sql.md).

  • It applies to mean metrics and to ordered funnels. Ratio and retention metrics are never adjusted. A mean metric with a threshold, a mean metric on a session property (such as $session_duration) and a funnel with a data-warehouse step are not adjusted either.

  • It reads a look-back window before each exposure (14 days by default), which makes the calculation heavier.

Baseline variant (stats_config.baseline_variant_key).

  • Every comparison is against the baseline. That is control, or the flag's first variant when it has no control, unless the experiment names another variant.

  • After a change of the baseline every delta, interval and significance is recalculated against the new one, and the baseline itself shows no delta. "Control shows a lift" and "all signs flipped" are this setting.

<!-- Source for maintainers (may rot): _resolve_sequential_settings in products/experiments/backend/hogql_queries/utils.py; products/experiments/backend/hogql_queries/cuped_config.py; get_baseline_variant_key in products/experiments/backend/hogql_queries/__init__.py. -->

Evidence to gather. stats_config in experiment-get, and the experiment's change history when a result moved without new data: a setting changed on the page recalculates the whole run (D8 in numbers-vs-sql.md). What a missing key means depends on the key:

  • A missing method reads as Bayesian, and a missing confidence level as 95%. The project's defaults for both are copied into an experiment when it is created.

  • frequentist.sequential_testing_enabled and cuped fall back to the project's current default.

experiment-setup-context returns the project's defaults (team_defaults) where that tool is available. Otherwise ask the user to read the project's experiment settings. A p-value of exactly 1.000 on a metric of a Frequentist experiment is a sign of sequential testing. A p-value below 1 does not rule it out.

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