--- name: scanning-experiments-with-replay-vision description: "Provisions a Replay Vision scanner scoped to one experiment's exposed sessions: sets `experiment_targeting` so the API derives the person-scoped exposure filter server-side, templates a prompt that stays comparable across variants, sizes credit spend against the experiment's own population, and creates the scanner disabled so its prompt can be previewed on real sessions before it sweeps.\nTRIGGER when: user wants Replay Vision to watch an experiment, asks to scan or analyze an experiment's recordings with AI, asks \"what are users actually doing in the test variant\", or wants a scanner scoped to an experiment's exposed sessions.\nDO NOT TRIGGER when: creating a general-purpose scanner not tied to an experiment (use creating-replay-vision-scanners), reading observations a scanner already produced (use exploring-replay-vision-observations), or manually browsing an experiment's recordings without AI analysis (use analyzing-experiment-session-replays)." --- # Scanning experiments with Replay Vision The job: _"I'm running an experiment. Watch the recordings and tell me what's actually happening in each variant."_ A Replay Vision scanner is a standing LLM probe over session recordings (see [[creating-replay-vision-scanners]] for the general mechanics). Scoping one to an experiment fixes the classic ways scanners go wrong, all at once: the exposure filter is **derived server-side** from the `experiment_targeting` field instead of hand-authored, the prompt is **templated** from the hypothesis and variants instead of vague, the population is **bounded** by enrollment, and the experiment's end date gives the scanner a natural end. This skill covers what is experiment-specific; the generic create/size mechanics stay in the parent skill. The flow: resolve the experiment → set `experiment_targeting` so the API derives the exposure filter → pick a template → size it → create **disabled** → preview the prompt on a few real sessions → let the user enable it. ## Step 1: Resolve the experiment `experiment-get` returns everything needed: `feature_flag_key`, the linked `feature_flag` (its `filters.multivariate.variants` list is the source of truth for variant keys — `parameters.feature_flag_variants` can be stale), `exposure_criteria`, `resolved_exposure_event`, `start_date`, `end_date`, and `status`. If the user didn't identify the experiment, resolve it via [[finding-experiments]] rather than guessing. You no longer derive the exposure event to build the scan query — the API does that from `experiment_targeting` (Step 2). You still need the event name for the per-variant readout join at the end; the readout section covers that derivation where it is used. Guards before doing anything else: - **Draft** (no `start_date`): there are no exposures and nothing to scan. Say so and stop. - **Stopped/complete**: a new scanner only sees sessions from creation time onward, so a concluded experiment needs a backfill over its run (see Limits). - **Running or exposure-frozen**: proceed. A frozen experiment stops enrolling but already-exposed users keep producing sessions, so scanning stays useful. - **Already half over**: the scanner watches only the remaining run. Say so, so a per-variant readout isn't mistaken for full-run coverage. ## Step 2: Point the scanner at the experiment The scanner carries no hand-built exposure filter. Set the `experiment_targeting` field and the API derives the exposure filter server-side, at scan time: ```json { "experiment_id": 123, "variant": null } ``` - `experiment_id` — the experiment from Step 1. - `variant` — one variant key to narrow the scan to that variant's exposed people, or `null` for every variant. **Default to `null` — one scanner for the whole experiment, not one per variant.** The readout splits by variant later (see the readout step). Spend is the same either way — credits are per observation — but one scanner keeps a single prompt version across variants (see Limits on version skew) and one readout, and its random `sampling_rate` is applied _after_ the exposure filter matches, so the sample itself does not bias a variant (the eligibility gates are another story — see Step 4). Set a single `variant` only when the user asks to watch one arm. **The API owns the exposure filter and its access control.** From `experiment_targeting` the server resolves the same exposed-person population the experiment's Recordings tab shows. The filter is **person-scoped**, so it covers people whose exposure event fired server-side or in an earlier session — sessions the old hand-built event filter missed. You do not build event or property filters for exposure, and you do not run a session-linkability or dedup check first; the server handles both. The API also rejects an `experiment_exposure` set directly inside `query`, and access-checks the targeted experiment, so a scanner can only reach an experiment its editor can view. **Keep `query` for non-exposure filters only.** Set `filter_test_accounts` from the experiment's own `exposure_criteria.filterTestAccounts`, defaulting to **`false` when absent** — that is what every experiment surface does (`get_test_accounts_filter` backend-side, `?? false` in the replay tab). A minimal query is enough: ```json { "kind": "RecordingsQuery", "filter_test_accounts": false } ``` Add other recording filters (duration, console errors, a specific page) only when the user asks. Leave exposure to `experiment_targeting`. **No `date_from`/`date_to`.** The scanner strips them on save (its 5-minute sweep controls time) and the estimate ignores them. ## Step 3: Scanner type and prompt template **Default to `classifier`.** A fixed tag set is what makes two variants comparable — free text does not aggregate into a per-variant delta. `scorer` is second choice when the question is "how much"; `monitor` and `summarizer` are for exploration, not comparison. Template hygiene, learned the hard way: name the changed surface concretely (not "the new feature"), keep the tag set small, and **always include an escape tag** (`never-reached` or similar) — a classifier must pick from its tags, and most exposed sessions never touch the changed surface, so without an escape tag the model is forced to invent friction on irrelevant sessions and the variants develop a fake delta. **Don't tell the model which variant is which.** It is tempting to write "control shows X, test shows Y" as context, but that is the one thing to leave out. The scanner has no idea which variant the session belongs to, and if the prompt supplies variant labels the model will confidently attach one anyway — in testing it reported "(test variant)" at confidence 1.0 on a session whose flag value was `control`, _even with an explicit instruction not to infer the variant_. Describe the surface and the forms it can take without labeling them ("the alert flow may appear either as one long form or as a stepped wizard — classify the experience either way"). The model doesn't need the mapping: variant attribution comes from the readout join, so the tags only have to describe behavior that means the same thing in either variant. Treat observation text as evidence about the **surface** the user saw, never about which **variant** they were assigned to — with labels in the prompt you cannot tell model error from a broken feature gate from the text alone; without them, that same disagreement becomes a detectable finding (see the readout step). Every prompt needs the **post-exposure framing** sentence: tell the model to focus on behavior after the point where the experiment's change would first be visible and ignore earlier activity. Be honest with the user that this is a request to the model, not an enforced window — scanners view the whole recording (see Limits). **The experiment creation wizard offers the same scanner from a checkbox.** Its prompt and tag set are the post-exposure friction template below, canonical in `experimentScannerPrompt` and `EXPERIMENT_SCANNER_TAGS` (`frontend/src/scenes/experiments/replayVisionScanner.ts`) — change them and this section together, and prefer matching them over inventing a new prompt when a user already has a wizard-created scanner. The wizard sets the population with `experiment_targeting`, the same as Step 2. Starter templates: 1. **"Did anyone notice?"** — `classifier`, tags `reached-and-interacted`, `reached-not-interacted`, `never-reached`. When an experiment lands flat, the numbers can't distinguish "the change did nothing" from "nobody encountered the change"; this can. Needs the user to describe the changed surface. Often the right first scanner. 2. **Post-exposure friction** — `classifier`, tags `confusion`, `hesitation`, `error-or-dead-end`, `smooth`, `never-reached`. The general "why is the test variant losing" probe. 3. **Funnel drop-off explainer** — `classifier` or `monitor`. Honest caveat: a standing `RecordingsQuery` cannot express "was exposed but never completed the funnel" — event filters assert presence, never absence — so a _standing_ scanner can only pose the drop-off question in the prompt over every exposed session, which is weaker and costs more. What does work today: derive drop-off session ids with `execute-sql` (exposure present, completion event absent, non-empty `$session_id`) and `vision-scanners-scan-session` a sample of them. **Don't also require the funnel's first step.** The analysis prepends exposure as the funnel's implicit first step, so the exposure _is_ the entry; requiring the first step on top of it would drop the sessions that bounced before reaching it — the drop-offs most worth watching. Where a funnel lists one event as several steps, "completion absent" becomes "fired fewer times than the series repeats it". PostHog also computes this drop-off bucket server-side with hardening the hand-rolled SQL lacks (the experiments session-buckets REST endpoint) — it is not exposed over MCP yet; prefer it over the SQL once it is. Say which path you're offering. 4. **Per-variant behavior summary** — `summarizer`. Exploration only: summaries do not aggregate into a delta. ## Step 4: Size it against the experiment, not the month Run the standard gut-check from [[creating-replay-vision-scanners]]: `vision-scanners-estimate` with the `query` **and the same `experiment_targeting`** you will save, then `vision-quota-get`, comparing **credits against credits** (`remaining` is `null` when the org is uncapped — then reason about absolute spend instead). Passing `experiment_targeting` matters: the estimate then derives the same exposure filter and counts only exposed sessions, so it forecasts the scanner's real spend. Experiment-specific corrections on top: - **The estimate's window is the wrong window.** It always measures a fixed 30-day lookback — `window_days` shrinks only when the team's recording history is shorter, never to the experiment's age. For an experiment younger than the window, `matched_sessions_in_window / window_days` dilutes the true rate across days the experiment wasn't running (a 3-day-old experiment is understated ~10×), and `estimated_credits_per_month` inherits the dilution. Compute sessions/day as `matched_sessions_in_window / min(window_days, days since start_date)`, and don't quote the monthly figure as the experiment's cost. - **The experiment gives a better bound than a monthly projection.** Total spend ≈ (exposed sessions/day × days remaining × `sampling_rate`) × `credits_per_observation` — a finite number. Use the experiment's expected remaining run time (`running_time_calculation.recommended_running_time` minus days elapsed, when set — that's its canonical home; it no longer lives in `parameters`). - **`sampling_rate` is the lever, not a compromise.** A qualitative read does not need every session: on a high-traffic experiment even 0.5–2% sampling yields plenty of observations. The random sample is applied after the query matches, so it does not bias either variant. Floor: non-zero rates below 0.0001 are rejected; `0` means paused. - **Two other gates are not variant-neutral.** The sweep (and the estimate) drop sessions under 15s total, under 10s of activity, or over 1h of activity, and a `focused`/`balanced` `sampling_mode` additionally keeps only roughly the top 25%/65% of sessions by surfacing score. Bounced and idle sessions are exactly what a `never-reached` tag is meant to count, so when one variant changes bounce behavior these filters clip the variants differently. Keep `sampling_mode: comprehensive` (the default) for experiment scanners, and read tag shares knowing sub-15s bounces never enter at all. - **Healthy exposures next to `matched_sessions_in_window ≈ 0` means sessions aren't being recorded** — replay disabled or sampled down, or traffic from an SDK that doesn't record. Surface it and stop rather than creating a scanner that will sit idle. Show the user the numbers before creating, per the parent skill. ## Step 5: Create disabled, preview, then hand over Create with `vision-scanners-create`, carrying the `experiment_targeting` from Step 2 and **`enabled: false`** — no schedule, no sweep spend, and on-demand triggers still work. Preview scans are not free, though: each one spends credits like any observation (the quota check runs unconditionally) and is rejected outright when the org is exhausted. Name it so it's findable, e.g. `Experiment scan: ·