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://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/diagnostic-snapshot.md

Diagnostic snapshot

Before asking clarifying questions, gather evidence directly. Most diagnostics in this skill can be confirmed or ruled out from data, and a question to the user is the fallback for what the tools cannot answer.

Read in the order below. Each step costs more than the one before, and most diagnostics are settled by the first three. Run the snapshot once and reuse the results across the dispatch table in SKILL.md.

StepReadCost
1Configuration: experiment-getOne read
2Stored results: experiment-metrics-recalculation-latest-retrieveOne read, never starts a calculation
3Changes: experiment-activityOne read
4Exposure data: experiment-results-getRuns the exposure query and every metric query that has no cached result
5Raw exposure events: execute-sqlA scan of events. Only for what steps 1 to 4 cannot show

Stored results

Powers the C and D groups, and every question about how fresh a number is.

experiment-metrics-recalculation-latest-retrieve returns the last finished calculation, including one in which some metrics failed: one entry per metric with the per-variant statistics, and for the run:

  • query_to — the data the numbers were computed against ends here. State it when you report a number. A calculation that runs after the experiment stopped ends at the end date. When the last calculation ran before the stop, query_to lies before end_date, by hours or by weeks, and the stored numbers miss the time between (D8 in numbers-vs-sql.md). Compare the two on every stopped experiment.

  • number_of_samples per variant — the units the metric counts as exposed. It gives the observed split without another call. It leaves out the units counted as $multiple and, when only_count_matured_users is on, the units whose metric window has not ended yet: it can be lower than the exposure counts, and it can differ between metrics. The stored results carry no exposures object: the count of people in several variants, the p-value of the sample ratio test and the daily series come from the exposure data below.

  • result_source — recalculation for a real run. timeseries_fallback means that no run has completed and the numbers are a placeholder built from the daily series: say so.

  • active_run — present while a run executes. The results returned are those of the run before. When no run has finished yet, they are the timeseries_fallback placeholder, or the running calculation itself: its status is pending or in_progress, and its results are empty or partial.

  • 404 — no results yet. The experiment has not run long enough, or was never calculated.

  • A metric with status: failed carries its error_message. That is a failed computation, not a result of zero.

  • An empty results, or a metric that is missing from it. The start date, the exposure criteria, the method, the excluded variants or the completed-window setting changed after the run, or the metric was added or edited after it. The stored rows no longer match the experiment and are left out until the next calculation (D8 in numbers-vs-sql.md). That is not a result of zero either.

A variant's result can carry validation_failures. The variant then has no chance to win, no p-value and no significance. A failure on the baseline leaves every variant without them:

ValueRule
not-enough-exposuresFewer than 50 exposures in the variant
not-enough-metric-dataFunnel or retention metric: fewer than 5 conversions in the variant. Ratio metric: a denominator of zero
baseline-mean-is-zeroThe baseline variant's total is 0 (D11 in numbers-vs-sql.md)

<!-- Source for maintainers (may rot): validate_variant_result in products/experiments/backend/hogql_queries/utils.py. Verify before citing. -->

MUST read stored results before any tool that computes. MUST NOT start with a tool that computes: experiment-results-get runs every metric query that has no cached result, and experiment-metrics-recalculation-create starts a calculation. Ask the user before starting a recalculation, and say that it recomputes every metric.

Exposure data

Powers A1, A2, A3, A7b, B0, C2 and the plateau check in empty-experiment.md.

experiment-results-get returns an exposures object next to the metric results. Call it when the diagnosis needs one of the values below. A split that the stored results already show as close to the configured one, on a complaint that is not about bias, does not need it. It is the experiment's own exposure query: the experiment's exposure event, current variant keys only, the test-account filter, the experiment's window, each person counted once at the first exposure. Variants in excluded_variants are left out of it, and on a group-aggregated flag it counts groups in place of persons. With an activation event (exposure_criteria.activation_config) it counts a person only from the first activation event at or after the first flag exposure, at the time of that event. The raw exposure queries below do not apply the activation event: they show the flag's calls.

  • exposures.total_exposures — people per variant. The key $multiple holds the people who were exposed to more than one variant under exclude handling. Under first_seen handling the key is absent.

  • exposures.sample_ratio_mismatch — p_value and the expected counts of the sample ratio test (A2). Null below 100 exposures.

  • exposures.bias_risk — present when the bias banner's conditions hold, with multiple_variant_percentage (A1).

  • exposures.timeseries[].exposure_counts — per variant, cumulative people by the day of their first exposure. A flat tail means no new people entered. It does not show whether the application still calls the flag: already-exposed people who return add nothing.

  • exposures.date_range — the window the counts cover.

Read off:

  • Total exposures — 0 means walk the B series. Under 50 in a variant, no result is computed for it (C2).

  • $multiple share — non-zero brings A1, A3 and A4 onto the table.

  • Observed split against the configured split — judge it by the p-value, not by eye (A2).

  • The daily series — a gap between the arms that grows in one direction (A9), an arm that stops growing (A7b, A6), all arms flat (the closing section of empty-experiment.md).

Three properties of this tool:

  • It sends every metric query of the experiment in one call. A query with a result younger than 24 hours is answered from the cache. The rest run. On an experiment with many or heavy metrics that is slow, and metric rows can fail (see "Metric rows with data: null"). Call it once per diagnosis.

  • refresh: true does not force a recomputation. Results younger than 24 hours come from the cache with or without it. last_refresh and is_cached on each row say how old a number of this tool is. query_to dates the stored results, which are another store.

  • Its experiment.status is derived from the dates only (draft, running, completed). Read paused and exposure_frozen from experiment-get.

<!-- Source for maintainers (may rot): ExperimentExposuresQueryRunner in products/experiments/backend/hogql_queries/experiment_exposures_query_runner.py; getExposures and getMetricResults in services/mcp/src/api/client.ts (refresh is sent as 'blocking', the API's default mode); transformExperimentResults in services/mcp/src/schema/experiments.ts. -->

Raw exposure events

Steps 1 to 4 say how many people the experiment counts. Raw events say what the application sent: responses that are no variant, the library, the identifier, the time of the last event. Query them when a diagnostic asks for exactly that.

Query rules

Every query in this skill follows them.

  • The default exposure event. For the default exposure: the experiment's resolved_exposure_event from experiment-get. Do not hardcode $feature_flag_called. When resolved_exposure_event is $feature_flag_called, also read $experiment_exposure: write event IN ('$feature_flag_called', '$experiment_exposure') wherever a query says event = '<resolved_exposure_event>', and count persons, not rows.

  • Both ends of the time range, at most 7 days per query, and one day first on a large project. The whole run of an experiment can be months of events. A property that is not a materialized column makes every row expensive: keep such a query to one day.

  • toDateTime('<timestamp>', 'UTC') for every timestamp. A bare datetime string is read in the project's timezone, which moves the window by the project's offset.

  • The flag's variant keys, from feature_flag.filters.multivariate.variants[].key, wherever a query counts people per variant.

  • The experiment's unit. The queries count persons. When the flag is aggregated by a group type (feature_flag.filters.aggregation_group_type_index is set), count $group_<index> in place of person_id and leave out the rows where it is empty. A count of persons on such an experiment is not comparable with its exposures.

  • No test-account filter. These queries include internal and test users, and the experiment's own numbers usually do not. Say so when the two are compared.

  • The exposure property filters. When exposure_criteria.exposure_config.properties is set, the experiment counts only the exposure events that match them (B12). Add the same conditions to the WHERE clause, or say that the query counts more events than the experiment.

Which event, which property
exposure_criteria.exposure_configEvent to queryVariant is in
Not set, or it names $feature_flag_calledresolved_exposure_event$feature_flag_response, with $feature_flag = the flag key
It names $experiment_exposure$experiment_exposure, whatever resolved_exposure_event says$feature_flag_response, with $feature_flag = the flag key
Any other eventThat event`$feature/<flag-key>`
An actionEvery event that the action matches: read the action's definition`$feature/<flag-key>`

The default event depends on two conditions. It is $experiment_exposure when the experiment started on or after 2026-09-01 (UTC) and the project is in the rollout of that event. Otherwise it is $feature_flag_called. resolved_exposure_event already holds the answer: read it, do not derive it.

When resolved_exposure_event is $feature_flag_called, also read $experiment_exposure (event IN ('$feature_flag_called', '$experiment_exposure')), and count persons, not rows. Ingestion stops writing $feature_flag_called to the events table for some organizations, and the copy then carries the exposures. Before that, a flag call and its copy are two rows for one exposure.

$experiment_exposure is a copy of $feature_flag_called, written at ingestion with the same properties. It is written for every string response other than true, false and the empty value: a variant key, and also holdout-<id> or a key in another spelling. A response of true, false or an empty value has no copy. So a question about responses that are no variant (A10 in bias-and-skew.md) needs $feature_flag_called.

The copy is switched on per project, apart from the setting that makes an experiment read it. When resolved_exposure_event is $experiment_exposure, the query window is on or after 2026-09-01 (UTC), that event has no rows for the flag, and $feature_flag_called has variant responses in the same window, the copy is not written for the project. That is not a fault of the SDK or of the application: say so, and point the user to PostHog support.

<!-- Source for maintainers (may rot): resolve_default_exposure_event and EXPERIMENT_EXPOSURE_EVENT_CUTOFF in products/experiments/backend/hogql_queries/exposure_query_logic.py; build_variant_property in experiment_exposure_query_builder.py; isMultivariateFeatureFlagCalledEvent in nodejs/src/ingestion/common/steps/event-processing/create-event-step.ts. -->

Raw responses
-- Default exposure event: what the application sent, per response
SELECT
    properties.$feature_flag_response AS response,
    count() AS events,
    uniq(person_id) AS persons,
    uniq(distinct_id) AS distinct_ids,
    min(timestamp) AS first_seen,
    max(timestamp) AS last_seen
FROM events
WHERE event = '<resolved_exposure_event>'
  AND properties.$feature_flag = '<flag-key>'
  AND timestamp >= toDateTime('<window_start>', 'UTC')
  AND timestamp < toDateTime('<window_end>', 'UTC')
GROUP BY response
ORDER BY events DESC
LIMIT 50
-- Custom exposure event: the values of the variant property
SELECT
    properties.`$feature/<flag-key>` AS variant_value,
    count() AS events,
    uniq(person_id) AS persons,
    uniq(distinct_id) AS distinct_ids,
    min(timestamp) AS first_seen,
    max(timestamp) AS last_seen
FROM events
WHERE event = '<custom-exposure-event>'
  AND timestamp >= toDateTime('<window_start>', 'UTC')
  AND timestamp < toDateTime('<window_end>', 'UTC')
GROUP BY variant_value
ORDER BY events DESC
LIMIT 50

Read off:

  • Rows that are no variant key. The experiment drops them: false, an empty value, a key with another spelling or case, holdout-<id>. false and an empty value exist only on $feature_flag_called: when resolved_exposure_event is $experiment_exposure, run the first query on $feature_flag_called to see them. They are not people in several variants, and no event ever carries $multiple. Whether they are a finding depends on who they are (A10 in bias-and-skew.md). When every row is one of them, walk the B series.

  • holdout-<id>. People in the experiment's holdout. Expected, and outside the analysis.

  • distinct_ids / persons above 1 for a variant: people evaluated the flag under several linked ids (A3).

  • last_seen per variant. One variant far behind the other means the application still sends the event for one arm only. All variants behind, on a running experiment, means the application stopped sending it. Both: the closing section of empty-experiment.md.

  • events far above persons is normal. The experiment counts each person once. When the query reads both flag-call events, events also counts a flag call and its copy as two rows.

By day and library

For A2 and A9: where a gap between the arms sits, and since when.

SELECT
    toDate(timestamp) AS day,
    properties.$lib AS lib,
    properties.$feature_flag_response AS response,
    count() AS events,
    uniq(person_id) AS persons
FROM events
WHERE event = '<resolved_exposure_event>'
  AND properties.$feature_flag = '<flag-key>'
  AND timestamp >= toDateTime('<window_start>', 'UTC')
  AND timestamp < toDateTime('<window_end>', 'UTC')
GROUP BY day, lib, response
ORDER BY day, lib, response
LIMIT 200

persons counts everyone who sent the event on that day, returning people included. The experiment's daily series counts each person once, on the day of the first exposure, so compare the two by their shape, not day by day. To see whether one arm's exposures come from a page the other arm never reaches (A9), group by properties.$pathname in place of lib, on one day.

Metric rows with data: null

Powers the "results won't load" complaint.

Each row in metrics.primary.results and metrics.secondary.results of experiment-results-get keeps its position, and a row whose query failed in this call has data: null. The tool sends every metric query at once, so on an experiment with many metrics some rows can fail on load or on a timeout while the metric itself is sound. The tool keeps no error text: any failed query gives data: null, so the row does not say why it failed.

A single call with data: null rows is not evidence that a metric is broken. Tell the two apart before reporting it:

  • Read the stored results. A metric with a completed result there computes. A metric with status: failed and an error_message there failed in the stored calculation too: inspect its definition (D11 in numbers-vs-sql.md).

  • Do not repeat experiment-results-get to see whether the rows fill in. Each call runs the failed queries again.

Two cautions:

  • Don't conflate the count of null rows with severity. Experiments with dozens of metrics show the most failed rows in one call, because more queries compete.

  • Backend results health ≠ the user's in-app loading experience. A complaint that the results page does not load, on an experiment whose stored results are complete, is a loading problem of the page. Do not report it as a broken metric. Many or very heavy metrics (a funnel with dozens of steps, an exposure filter on a property that is expensive to read) are a cause the user can change.

Changes after launch

Powers A6, E3, E5, E7, E9, E16 and E17.

Read the changes in every diagnosis. If the user reports a surprising change (the ratio flipped, the numbers moved after an edit, the flag went to 0/100), read them first, before the stored results.

  • experiment-activity { id: <experiment_id> } — the changes of the experiment, of the holdout and shared metrics attached to it now, and of its linked flag, newest first, each with the field values before and after. The flag's changes are left out when the caller has no access to the flag: a history without any flag entry is then no evidence that the flag did not change. Every lifecycle action is a change of its field: a launch and a reset change start_date, an end changes end_date, a pause and a resume change the flag's active, a freeze and an unfreeze change the flag's release conditions, a ship changes the flag's variants. Newer actions also have a named entry (paused, resumed, exposure_frozen, exposure_unfrozen, variant_shipped). Older ones do not, so the field change is the evidence and a missing named entry proves nothing.

  • feature-flags-activity-retrieve { id: <feature_flag_id> } — the flag's own history, with the same diffs. It is the shorter read for a question about the split, the release conditions or the flag's state.

The history is long on an experiment that was running before mid-September 2026: most of its older entries are updates of the running-time estimate (running_time_calculation), which change nothing in the analysis. Newer updates of the estimate leave no entry. Page with limit, skip those entries, and read back to the launch before you say that nothing changed.

The split of the run is in the history, not on the flag. experiment-get shows the flag as it is now. After a ship that is 100/0, and after any edit it is the edited value. The split that the exposures were assigned under is the flag's value at start_date, plus every change since. After a reset, start_date is the relaunch: an edit before it belongs to the earlier run.

  • advanced-activity-logs-list — the same log across the project, where the organization's plan includes the tool. It returns the field-level diffs when fields is omitted or includes detail.changes.

A8 (a change of the distinct_id strategy) is a change in the user's code and does not show up here — diagnose it from event-side identity signals, not the activity log.

Handing off the snapshot

If the snapshot already disproves a diagnostic, skip it; if it confirms one, lead the response with the evidence ("the data shows X → that's diagnostic Y").

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
PostHog
Publisher
PostHog (Official)
Source
PostHog/skills · skills/omnibus/diagnosing-experiment-health · commit a5d30e7
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by PostHog or PostHog.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download diagnosing-experiment-health.zip