Skip to content

feat(replay-vision): sample an experiment scanner's variants evenly - #108868

Open
ksvat wants to merge 5 commits into
posthog/rv-experiment-variant-attributionfrom
posthog/rv-experiment-balanced-sampling
Open

ksvat wants to merge 5 commits into
posthog/rv-experiment-variant-attributionfrom
posthog/rv-experiment-balanced-sampling

Conversation

@ksvat

@ksvat ksvat commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Problem

An experiment scanner samples with one rate, so coverage follows rollout: at a 10% rate over 1,000 exposed sessions on a 90/10 split, a team gets ~90 control observations and ~10 test. The small arm — usually the variant under test — starves, and the comparison the scanner exists for has nothing to compare. Layer 4 of stack #108815, per the "Experiment scanner: backend plan" design doc; sits on #108862.

Changes

  • Balanced sampling for the experiment scanner type, on by default (balance_variants in the scanner config, added in the type's first layer): each sweep counts the exposed persons per watched variant over the experiment window and computes one rate per variant, r / (k · share), capped at 1. Counts rather than the flag's rollout percentages, so a rollout that changed mid-experiment plans against the mix actually being sampled (per review). A capped variant's unspent budget redistributes to the others, so a small variant can never shrink the total; the same example now yields ~50 observations per arm for the same spend.
  • The sampling decision lives in the candidate query's HAVING, as one salted-hash threshold per attributed variant (multiIf), so decisions stay stable across sweeps and backfills the way plain sampling does.
  • The exposure join projects the attributed variant when balancing is on (a new opt-in on the recordings list query); the population is unchanged.
  • Backfills share the query class and salt, so they balance the same buckets. Counts are recomputed each tick (60s budget, metered as the scanner's reads, failing open to plain sampling), so a traffic shift adjusts on the next sweep.
  • The volume estimate keeps projecting with the plain rate: redistribution spends exactly the configured budget (effective_rate ≡ rate, cap or no cap), so balancing never moves the projection — the invariant has its own test.
  • Each observation's snapshot records the rates its dispatching tick sampled at, so even per-variant counts don't read as even traffic. Mechanical: the rates thread from the tick's activity output through the apply inputs into the snapshot (all additive dataclass fields with defaults; no Temporal command changes), plus regenerated OpenAPI types.

Balancing cannot create sessions a small variant does not have: a 5% arm on a low-traffic page is sampled whole and stays thin.

How did you test this code?

  • New test_variant_sampling.py: the doc's 90/10-at-10% example gives even coverage at the same total; a capped small variant frees its budget; watched-subset normalization; zero-share and single-variant edge cases; the multiIf predicate's per-variant thresholds and its no-op when every rate is 1.
  • New ClickHouse test in test_scanner_candidate_query.py: with rates control=0/test=1 only the test-variant session survives the sweep query, and the unbalanced control run keeps both, so the join projection and the per-variant gate are exercised end to end.
  • test_temporal.py: the dispatching tick's rates land on the observation's snapshot.
  • Affected suites (candidate query, sweep, backfills, estimate refresh, temporal): 467 passed. TestScannerCandidateQueryAgainstClickHouse has order-dependent flakes in this sandbox that reproduce on a clean checkout (2–5 failures vary per run, pass in isolation); not a regression from this diff.
  • Repo-wide mypy clean, ruff clean, hogli build:openapi regenerated.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

The replay-vision flag still gates the whole product, and only experiment scanners (no UI entry point yet) reach this path.

Automatic notifications

  • Publish to changelog?

Docs update

None: no doc under docs/ covers scanner sampling.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code (PostHog Desktop cloud task), Claude Fable 5

  • Layer 4 of stack #108815, from the team lead's "Experiment scanner: backend plan".
  • Skills invoked: /writing-tests, /writing-dataclasses, /writing-code-comments, /writing-pr-descriptions.
  • CodeRabbit local pass skipped: cloud task run.
  • Decision along the way: rates use water-filling (equal budget slices, capped variants freeing theirs) rather than the doc's one-shot formula, so redistribution works for any number of capped variants; the two agree whenever no cap binds.
  • Public artifact: all test data is invented; no session material is included.

Created with PostHog Desktop

🤖 Generated with Claude Code

@ksvat ksvat self-assigned this Sep 30, 2026
@ksvat
ksvat added this pull request to stack #108815 September 30, 2026 00:58
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ Complexity (TypeScript) — clean

Cyclomatic complexity above the limit in changed typescript files (10 for production files, 15 for test files). Warn only: worth simplifying when you next touch these functions.

⚠️ Duplication (Python) — 138 new duplicated blocks (worst 278 tokens)

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
products/tasks/backend/logic/services/agent_server_launcher.py:671 products/tasks/backend/logic/services/sandbox.py:649 40 278
products/tasks/backend/logic/services/agent_server_launcher.py:304 products/tasks/backend/logic/services/docker_sandbox.py:950 38 246
products/warehouse_sources/backend/temporal/data_imports/pipelines/core/account_property_row_sink_test.py:129 products/warehouse_sources/backend/temporal/data_imports/pipelines/core/account_property_row_sink_test.py:251 44 238
products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/woocommerce/source.py:1 29 195
products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/instantly/source.py:8 25 187
products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/paymongo/source.py:5 25 187
products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/postmark/source.py:3 24 182
products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/webflow/source.py:5 24 182
products/canvas/backend/presentation/serializers.py:1545 products/tasks/backend/presentation/serializers.py:3145 13 168
products/warehouse_sources/backend/temporal/data_imports/sources/growthbook/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/productive/source.py:1 23 168
products/warehouse_sources/backend/temporal/data_imports/sources/growthbook/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/sevdesk/source.py:2 23 168
products/warehouse_sources/backend/temporal/data_imports/sources/docuseal/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/revolut_merchant/source.py:1 21 166
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/heygen/source.py:1 21 162
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/kapa_ai/source.py:1 21 162
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/lexware_office/source.py:1 21 162
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/retell_ai/source.py:1 21 162
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/sim/source.py:1 21 162
products/warehouse_sources/backend/temporal/data_imports/sources/browse_ai/source.py:22 products/warehouse_sources/backend/temporal/data_imports/sources/fintoc/source.py:10 22 159
products/warehouse_sources/backend/temporal/data_imports/sources/browse_ai/source.py:24 products/warehouse_sources/backend/temporal/data_imports/sources/revenuecat/source.py:16 20 159
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 21 159
products/warehouse_sources/backend/temporal/data_imports/sources/companycam/source.py:3 products/warehouse_sources/backend/temporal/data_imports/sources/growthbook/source.py:5 19 158
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/papersign/source.py:1 21 157
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/turso/source.py:1 21 157
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/adroll/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/aha_ideas/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/aircall/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/airwallex/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/alegra/source.py:20 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/alguna/source.py:16 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/apitally/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/apollo/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/asaas/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/asana/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/autumn/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/avalara/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/azure_devops/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/babelforce/source.py:16 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/back_market/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bamboohr/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/beehiiv/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bettermode/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bigeye/source.py:16 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bill_com/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/billomat/source.py:20 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bluesky/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/braintree/source.py:21 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/brevo/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/bugherd/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/buildbetter/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/buttondown/source.py:16 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/campaign_monitor/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/canvas_lms/source.py:16 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/capsule_crm/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/cast_ai/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/census/source.py:18 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/chargebee/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/chartmogul/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/clari/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/cliniko/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/clockify/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/cloudability/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/cloudinary/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/cloudsmith/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/codemagic/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/codescene/source.py:15 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/commercetools/source.py:17 16 144
products/warehouse_sources/backend/temporal/data_imports/sources/browse_ai/source.py:26 products/warehouse_sources/backend/temporal/data_imports/sources/zapsign/source.py:13 18 144
products/warehouse_sources/backend/temporal/data_imports/sources/adjust/source.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/buy_me_a_coffee/source.py:21 16 141
products/warehouse_sources/backend/temporal/data_imports/sources/browse_ai/source.py:27 products/warehouse_sources/backend/temporal/data_imports/sources/calendly/source.py:32 17 141
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:17 products/warehouse_sources/backend/temporal/data_imports/sources/adjust/source.py:21 15 140
products/warehouse_sources/backend/temporal/data_imports/sources/convertkit/convertkit.py:4 products/warehouse_sources/backend/temporal/data_imports/sources/gainsight_px/gainsight_px.py:7 19 140
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/asknicely/source.py:16 16 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/churnkey/source.py:15 16 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/circleci/source.py:15 16 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/codecov/source.py:17 16 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:16 products/warehouse_sources/backend/temporal/data_imports/sources/coinmarketcap/source.py:15 16 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:17 products/warehouse_sources/backend/temporal/data_imports/sources/canny/source.py:18 15 139
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/aftership/source.py:20 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/apify_dataset/source.py:22 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/app_store_connect/source.py:29 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/aws_organizations/source.py:20 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/bigcommerce/source.py:18 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/browser_use/source.py:18 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/buildkite/source.py:23 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/cal_com/source.py:24 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/campfire/source.py:18 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/clickup/source.py:18 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/cloudzero/source.py:24 14 137
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/companycam/source.py:8 14 135
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/ecb_data_portal/source.py:6 14 135
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/agilecrm/source.py:16 12 135
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/aha/source.py:16 12 135
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/amazon_ads/source.py:20 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/amplitude/source.py:19 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/anthropic/source.py:27 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/appdynamics/source.py:25 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/automox/source.py:19 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/aviationstack/source.py:17 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/awin/source.py:22 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/azure_cost_management/source.py:16 14 132
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/concord/source.py:10 14 130
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/clerk/source.py:17 12 130
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/close/source.py:17 12 130
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/source.py:1 products/warehouse_sources/backend/temporal/data_imports/sources/ynab/source.py:1 18 126
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:32 products/warehouse_sources/backend/temporal/data_imports/sources/firecrawl/firecrawl.py:12 18 119
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:32 products/warehouse_sources/backend/temporal/data_imports/sources/snyk/snyk.py:16 18 118
products/warehouse_sources/backend/temporal/data_imports/sources/brex/brex.py:8 products/warehouse_sources/backend/temporal/data_imports/sources/gitbook/gitbook.py:6 18 117
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:33 products/warehouse_sources/backend/temporal/data_imports/sources/openaq/openaq.py:15 17 116
products/warehouse_sources/backend/temporal/data_imports/sources/ably/source.py:29 products/warehouse_sources/backend/temporal/data_imports/sources/browse_ai/source.py:30 13 115
products/warehouse_sources/backend/temporal/data_imports/sources/ably/source.py:31 products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/source.py:21 11 115
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:35 products/warehouse_sources/backend/temporal/data_imports/sources/eventzilla/eventzilla.py:36 15 114
posthog/models/property/util.py:138 posthog/models/test/test_event_model.py:356 11 113
products/warehouse_sources/backend/temporal/data_imports/sources/buy_me_a_coffee/buy_me_a_coffee.py:11 products/warehouse_sources/backend/temporal/data_imports/sources/descope/descope.py:9 11 107
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:32 products/warehouse_sources/backend/temporal/data_imports/sources/campaign_monitor/campaign_monitor.py:18 12 105
products/workflows/backend/models/hog_flow/hog_flow.py:150 products/workflows/backend/models/hog_flow/hog_flow_template.py:45 14 105
products/warehouse_sources/backend/temporal/data_imports/sources/algolia/algolia.py:20 products/warehouse_sources/backend/temporal/data_imports/sources/fullstory/fullstory.py:17 11 104
products/replay_vision/backend/temporal/backfill_workflow.py:145 products/replay_vision/backend/temporal/sweep_workflow.py:233 18 101
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:36 products/warehouse_sources/backend/temporal/data_imports/sources/sim/sim.py:13 18 98
products/warehouse_sources/backend/temporal/data_imports/sources/gitbook/gitbook.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/metronome/metronome.py:34 12 98
products/warehouse_sources/backend/temporal/data_imports/sources/gitbook/gitbook.py:18 products/warehouse_sources/backend/temporal/data_imports/sources/reverb/reverb.py:21 12 98
posthog/hogql_queries/insight_actors_query_options_runner.py:32 posthog/hogql_queries/insight_actors_query_runner.py:51 16 97
products/canvas/backend/presentation/serializers.py:1499 products/tasks/backend/presentation/serializers.py:3090 23 97
products/warehouse_sources/backend/temporal/data_imports/sources/convex/source.py:3 products/warehouse_sources/backend/temporal/data_imports/sources/pinecone/source.py:5 11 96
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:21 products/warehouse_sources/backend/temporal/data_imports/sources/revolut_merchant/revolut_merchant.py:8 11 90
products/warehouse_sources/backend/temporal/data_imports/sources/aha_ideas/aha_ideas.py:25 products/warehouse_sources/backend/temporal/data_imports/sources/freshservice/freshservice.py:19 15 90
products/warehouse_sources/backend/temporal/data_imports/sources/appdynamics/source.py:220 products/warehouse_sources/backend/temporal/data_imports/sources/gainsight_px/source.py:64 13 89
products/warehouse_sources/backend/temporal/data_imports/sources/buy_me_a_coffee/buy_me_a_coffee.py:21 products/warehouse_sources/backend/temporal/data_imports/sources/kapa_ai/kapa_ai.py:15 11 87
products/warehouse_sources/backend/temporal/data_imports/sources/brex/brex.py:17 products/warehouse_sources/backend/temporal/data_imports/sources/retell_ai/retell_ai.py:13 12 85
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:32 products/warehouse_sources/backend/temporal/data_imports/sources/baseten/baseten.py:18 11 84
products/warehouse_sources/backend/temporal/data_imports/sources/acculynx/acculynx.py:32 products/warehouse_sources/backend/temporal/data_imports/sources/yousign/yousign.py:29 11 84
products/tasks/backend/logic/services/agent_server_launcher.py:342 products/tasks/backend/logic/services/docker_sandbox.py:992 20 82
products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v2/pipeline.py:70 products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/pipeline.py:87 11 82
products/warehouse_sources/backend/temporal/data_imports/sources/fullstory/fullstory.py:172 products/warehouse_sources/backend/temporal/data_imports/sources/zoom/zoom.py:79 11 79
products/warehouse_sources/backend/temporal/data_imports/sources/lexware_office/lexware_office.py:5 products/warehouse_sources/backend/temporal/data_imports/sources/turso/turso.py:6 14 78
products/warehouse_sources/backend/temporal/data_imports/sources/kapa_ai/source.py:53 products/warehouse_sources/backend/temporal/data_imports/sources/lexware_office/source.py:53 14 76
products/metrics/backend/metric_event_samples_query_runner.py:13 products/metrics/backend/metric_query_runner.py:15 11 72
products/metrics/backend/metric_query_runner.py:435 products/metrics/backend/metric_samples_query_runner.py:25 12 72
products/warehouse_sources/backend/temporal/data_imports/sources/givebutter/givebutter.py:93 products/warehouse_sources/backend/temporal/data_imports/sources/ynab/ynab.py:40 15 70
⚠️ Duplication (TypeScript) — 4 new duplicated blocks (worst 139 tokens)

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx:196 products/desktop/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx:162 20 139
products/desktop/packages/core/src/sessions/processKilledNotice.ts:9 products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts:774 24 130
products/ai_observability/frontend/redesign/trace/sampleFixtures/anthropicMessagesThinkingToolUse.ts:146 products/ai_observability/frontend/redesign/trace/sampleFixtures/langchainCerebrasImageError.ts:122 20 121
products/metrics/frontend/components/MetricsSamplesPanel.tsx:55 products/metrics/frontend/components/TraceMetricSamples.tsx:77 21 96
🚨 Comment density — 11% of added code lines are comments (69 of 622)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
products/replay_vision/backend/tests/test_variant_sampling.py 15 128
products/replay_vision/backend/queries/variant_sampling.py 13 175
products/replay_vision/backend/tests/test_scanner_candidate_query.py 8 115
products/replay_vision/backend/queries/scanner_candidate_query.py 5 58
posthog/session_recordings/queries/session_recording_list_from_query.py 4 22
products/replay_vision/backend/queries/scanner_volume_estimate.py 3 3
products/replay_vision/backend/temporal/activities/backfill.py 3 15
products/replay_vision/backend/temporal/snapshots.py 3 4

This check does not block merging. It updates on every push and clears when the share drops.

⚠️ Bundle size — 🔺 +203.3 KiB (+0.3%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 69.22 MiB · 🔺 +203.3 KiB (+0.3%)

File Size Δ vs base
posthog-app/_parent/products/autoresearch/frontend/AutoresearchPipelineScene.js 53.0 KiB 🔺 +53.0 KiB (new)
posthog-app/_parent/products/ai_observability/frontend/AIObservabilityTraceScene.js 179.4 KiB 🔺 +41.1 KiB (+29.7%)
posthog-app/_parent/products/tasks/frontend/spaces/SpaceScene.js 21.7 KiB 🔺 +21.7 KiB (new)
posthog-app/_parent/products/autoresearch/frontend/AutoresearchNewScene.js 16.1 KiB 🔺 +16.1 KiB (new)
render-query/src/render-query/render-query.js 20.19 MiB 🔺 +15.0 KiB (+0.1%)
posthog-app/_parent/products/posthog_ai/frontend/scenes/TaskTracker/components/EmbeddedTaskComposerImpl.js 12.9 KiB 🔺 +12.9 KiB (new)
posthog-app/_parent/products/tasks/frontend/spaces/SpacesScene.js 9.1 KiB 🔺 +9.1 KiB (new)
toolbar/src/toolbar/debug/chunk-EventDebugMenu.js 300.2 KiB 🔺 +7.4 KiB (+2.5%)
posthog-app/_parent/products/signals/frontend/inbox/InboxScene.js 457.3 KiB 🔺 +5.3 KiB (+1.2%)
exporter/_parent/products/posthog_ai/frontend/scenes/TaskTracker/components/EmbeddedTaskComposerImpl.js 5.1 KiB 🔺 +5.1 KiB (new)
posthog-app/_parent/products/replay_vision/frontend/replay_scanners/ReplayScannersScene.js 98.0 KiB 🔺 +4.8 KiB (+5.1%)
posthog-app/src/scenes/AuthenticatedShell.js 272.5 KiB 🔺 +3.3 KiB (+1.2%)
posthog-app/_parent/products/workflows/frontend/Workflows/WorkflowScene.js 53.8 KiB 🔺 +3.1 KiB (+6.1%)
posthog-app/_parent/products/autoresearch/frontend/AutoresearchScene.js 13.9 KiB 🟢 -1.3 KiB (-8.8%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

✅ Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.60 MiB · 22 files 🔺 +3.7 KiB (+0.2%) █████████░ 86.9% of 1.84 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
3.54 MiB · 629 files 🔺 +4.5 KiB (+0.1%) █████████░ 87.9% of 4.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
7.45 MiB · 2,385 files 🔺 +44.7 KiB (+0.6%) █████████░ 89.3% of 8.34 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/index.tsx
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
854 B src/scenes/ChunkLoadErrorBoundary.tsx
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
216.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
100.5 KiB src/lib/api.ts
89.6 KiB src/products.tsx
69.4 KiB src/lib/lemon-ui/icons/icons.tsx
40.1 KiB src/lib/utils/eventUsageLogic.ts
38.7 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
33.9 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
28.5 KiB src/scenes/scenes.ts
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
279.7 KiB src/taxonomy/core-filter-definitions-by-group.json
216.9 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
102.7 KiB ../packages/quill/packages/quill/dist/index.js
100.5 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
89.6 KiB src/products.tsx

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

✅ Toolbar bundle — eager 2.17 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.17 MiB · 19 files 🔺 +4.9 KiB (+0.2%) ████░░░░░░ 38.0% of 5.72 MiB
Deferred (lazy) 2.10 MiB · 44 files 🔺 +7.4 KiB (+0.3%) n/a — loads on demand
Loader dist/toolbar.js 1.2 KiB no change █░░░░░░░░░ 6.0% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
813.3 KiB dist/toolbar/toolbar-app-BW77XXDE.css
651.8 KiB dist/toolbar/chunk-chunk-XQRTS2G5.js
259.4 KiB dist/toolbar/chunk-chunk-4EKE7GSM.js
138.3 KiB dist/toolbar/chunk-chunk-ZLUZVIUR.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
75.2 KiB dist/toolbar/toolbar-app-47JXF64O.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
35.6 KiB dist/toolbar/chunk-chunk-S2X4KXBC.js
21.0 KiB dist/toolbar/chunk-chunk-AM7RRUA3.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

✅ Dist folder size — 🔺 +2.37 MiB (+0.2%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 951.74 MiB · 🔺 +2.37 MiB (+0.2%)

ℹ️ MCP UI apps size — 33 app(s), 17633.1 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 597.9 KB 199.2 KB
action 454.1 KB 199.2 KB
action-list 564.2 KB 199.2 KB
cohort 453.1 KB 199.2 KB
cohort-list 563.2 KB 199.2 KB
email-template 452.9 KB 199.2 KB
error-details 469.6 KB 199.2 KB
error-issue 454.5 KB 199.2 KB
error-issue-list 564.8 KB 199.2 KB
experiment 561.3 KB 199.2 KB
experiment-list 564.9 KB 199.2 KB
experiment-results 566.3 KB 199.2 KB
feature-flag 566.8 KB 199.2 KB
feature-flag-list 570.5 KB 199.2 KB
feature-flag-testing 457.3 KB 199.2 KB
inline-scan 453.6 KB 199.2 KB
insight-actors 562.3 KB 199.2 KB
invite-email-preview 452.3 KB 199.2 KB
llm-costs 559.3 KB 199.2 KB
session-recording 455.3 KB 199.2 KB
survey 454.7 KB 199.2 KB
survey-global-stats 561.9 KB 199.2 KB
survey-list 564.9 KB 199.2 KB
survey-stats 561.9 KB 199.2 KB
trace-span 453.5 KB 199.2 KB
trace-span-list 564.1 KB 199.2 KB
vision-observation-list 563.3 KB 199.2 KB
workflow 453.4 KB 199.2 KB
workflow-list 563.5 KB 199.2 KB
loops-review 457.8 KB 199.2 KB
query-results 774.1 KB 199.2 KB
render-ui 858.1 KB 199.2 KB
visual-review-snapshots 457.9 KB 199.2 KB
✅ Playwright — all passed

All tests passed.

View test results →

✅ Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 0 Safe | 2 Needs Review | 0 Blocked

⚠️ Needs Review

May have performance impact

replay_vision.0102_alter_replayscanner_scanner_type
  └─ #1 ⚠️ AlterField
     Field alteration may cause table locks or data loss (check if changing type or constraints)
     model: replayscanner, field: scanner_type, field_type: CharField
replay_vision.0103_alter_replayobservation_error_reason
  └─ #1 ⚠️ AlterField
     Field alteration may cause table locks or data loss (check if changing type or constraints)
     model: replayobservation, field: error_reason, field_type: TextField

Last updated: 2026-09-30 15:54 UTC (6b49f8c)

@trunk-io

trunk-io Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

Failed Test Failure Summary Logs
personalAPIKeysLogic leaves the auto-selected feature_flag:write removable The test exceeded the maximum allowed time of 5000 ms and did not complete. Logs ↗︎

View Full Report ↗︎ ⋅ Docs

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Experiment scanners can derive per-variant sampling rates from exposure counts and apply them in candidate queries. Sweep and backfill workflows pass those rates to child scanner inputs. Observation snapshots and API types include optional sampling rates. Session recording queries can opt into projecting experiment exposure attribution.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to f4ffd

Balanced sampling preserves downstream access checks, but exposure counting should also validate the scanner or backfill creator. The remaining risk is bounded to an internal unauthorized read, with no established disclosure to unauthorized users.

Security Architecture Review

Security architecture risk: 🔵 Low · up to f4ffd

A new background exposure-count read does not check the experiment permissions of the person it runs for. Later checks block unauthorized candidate delivery and observation access, limiting the demonstrated exposure to internal reads within one project.

Retained concerns

  • Low · security · observed: The new balanced-sampling path reads experiment exposure counts with a service identity before checking experiment access. An enabled scanner whose creator retains recording access but loses experiment access can therefore continue triggering internal exposure reads, even though the subsequent candidate query refuses dispatch.
Security review details

Security Blast Radius

  • inferred — The demonstrated unauthorized operation is an internal exposure-population read for one team's experiment and selected variants per invocation. An active scanner can repeat it across ticks. Linkage constrains the experiment to the supplied team, and the helper returns aggregate counts rather than person identifiers; cross-tenant reachability or external count disclosure is not established.

Security Findings and Attack Paths

  • observed — The retained authorization finding concerns a newly added service-side read before experiment access is evaluated. A scanner creator's loss of experiment access does not prevent count execution, although the subsequent candidate query refuses dispatch. The separate deferred candidate remains uncertainty, not a second finding.

Trust Boundaries and Controls

  • observed — Downstream controls materially contain the finding: denied candidate queries stop delivery, and observation reads enforce scanner and recording access, current experiment access, and snapshot-specific experiment access. These checks protect the inspected output paths but do not authorize the preceding count read.

Resilience and Maintainability Implications

  • observed — Count failure degrades only balancing. Experiment authorization failure returns no sweep candidates without advancing progress, or cancels the backfill, rather than falling back to unauthorized candidate delivery.

Hardening Proposals

  • proposed — Carry the initiating principal into exposure planning and apply the shared experiment access check before linkage resolution or count execution. Keep authorization rejection distinct from an operational count failure that permits plain-sampling fallback.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description is complete and follows the required template. It explains the problem, user-visible changes, testing, release status, documentation status, agent context, and design decisions. It inc…
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
products/replay_vision/backend/temporal/activities/backfill.py-198-198 (1)

198-198: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Pass the variant sampling plan to the backfill count.

When balanced sampling applies, _enumerate omits variant_sampling_rates, so WindowedCandidateQuery uses the single-rate predicate. Backfill ticks use per-variant rates. The count stored as total_count can therefore differ from the candidates walked, skewing progress and credit estimates. Build the plan from the snapshot and pass its rates to the count query.

🐛 Suggested fix
 from products.replay_vision.backend.queries.scanner_candidate_query import (
     BACKFILL_CANDIDATE_QUERY_TYPE,
     BACKFILL_COUNT_QUERY_TYPE,
     WindowedCandidateQuery,
 )
+from products.replay_vision.backend.queries.variant_sampling import variant_sampling_plan_for_scope
@@
     ) -> int:
         snapshot = BackfillScannerSnapshot.from_scanner(scanner)
+        variant_plan = variant_sampling_plan_for_scope(
+            self.team,
+            scope=snapshot.experiment_scope(),
+            scanner_config=snapshot.scanner_config,
+            sampling_rate=snapshot.sampling_rate,
+        )
         return WindowedCandidateQuery(
@@
             sampling_mode=snapshot.sampling_mode,
             exclude_observed_by_scanner=str(scanner.id) if exclude_observed else None,
             max_execution_time_seconds=ENUMERATION_MAX_EXECUTION_SECONDS,
+            variant_sampling_rates=variant_plan.rates if variant_plan is not None else None,
         ).count(query_type=BACKFILL_COUNT_QUERY_TYPE)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 6b4e0244-3a0e-4ac6-ab70-d9afa29f24aa

📥 Commits

Reviewing files that changed from the base of the PR and between 771afd5 and a3dc70b.

⛔ Files ignored due to path filters (1)
  • products/replay_vision/frontend/generated/api.schemas.ts is excluded by !**/generated/**
📒 Files selected for processing (20)
  • posthog/session_recordings/queries/session_recording_list_from_query.py
  • products/experiments/backend/facade/replay.py
  • products/replay_vision/backend/api/observations.py
  • products/replay_vision/backend/queries/scanner_candidate_query.py
  • products/replay_vision/backend/queries/scanner_volume_estimate.py
  • products/replay_vision/backend/queries/variant_sampling.py
  • products/replay_vision/backend/temporal/activities/backfill.py
  • products/replay_vision/backend/temporal/activities/create_observation.py
  • products/replay_vision/backend/temporal/activities/find_scanner_candidates.py
  • products/replay_vision/backend/temporal/backfill_types.py
  • products/replay_vision/backend/temporal/backfill_workflow.py
  • products/replay_vision/backend/temporal/snapshots.py
  • products/replay_vision/backend/temporal/sweep_types.py
  • products/replay_vision/backend/temporal/sweep_workflow.py
  • products/replay_vision/backend/temporal/types.py
  • products/replay_vision/backend/temporal/workflow.py
  • products/replay_vision/backend/tests/test_scanner_candidate_query.py
  • products/replay_vision/backend/tests/test_temporal.py
  • products/replay_vision/backend/tests/test_variant_sampling.py
  • services/mcp/src/api/generated.ts

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment thread products/replay_vision/backend/queries/variant_sampling.py
Comment thread products/replay_vision/backend/queries/variant_sampling.py Outdated
Comment thread products/replay_vision/backend/queries/variant_sampling.py Outdated
@ksvat
ksvat force-pushed the posthog/rv-experiment-balanced-sampling branch 2 times, most recently from 2682501 to 44db3f8 Compare September 30, 2026 01:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
products/replay_vision/backend/tests/test_variant_sampling.py (1)

96-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the test imports to module scope.

Add variant_sampling_plan_for_scope to the existing planner import. Move create_experiment to the import section.

As per coding guidelines: “Always place imports at the top of the file (module level), never inside functions or methods (local imports).”

Proposed change
-        from products.replay_vision.backend.queries.variant_sampling import variant_sampling_plan_for_scope
-        from products.replay_vision.backend.tests.helpers import create_experiment

Update the module-level imports:

from products.replay_vision.backend.queries.variant_sampling import (
    plan_variant_sampling,
    variant_sampling_plan_for_scope,
)
from products.replay_vision.backend.tests.helpers import create_experiment

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 88ef3214-6f3c-4ded-bf53-08cc8adbca45

📥 Commits

Reviewing files that changed from the base of the PR and between a3dc70b and 44db3f8.

⛔ Files ignored due to path filters (1)
  • products/replay_vision/frontend/generated/api.schemas.ts is excluded by !**/generated/**
📒 Files selected for processing (5)
  • products/replay_vision/backend/api/observations.py
  • products/replay_vision/backend/queries/variant_sampling.py
  • products/replay_vision/backend/temporal/activities/find_scanner_candidates.py
  • products/replay_vision/backend/tests/test_variant_sampling.py
  • services/mcp/src/api/generated.ts

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 9 remain after this review.

@ksvat
ksvat marked this pull request as ready for review September 30, 2026 03:34
@ksvat ksvat added the stamphog Request AI approval (no full review) label Sep 30, 2026
@github-actions
github-actions Bot requested a deployment to preview-pr-108868 September 30, 2026 03:34 In progress
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
🛠️ Admin inspect & debug state in hogland
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit 6b49f8c · box box-b38936c5812b · ready in 848s (push → usable) · build log · rebuilds on every push, torn down on close

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested review from a team, TueHaulund, arnohillen and fasyy612 and removed request for a team September 30, 2026 03:35
@pr-assigner-resolver-posthog

Copy link
Copy Markdown

👀 Auto-assigned reviewers

These soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:

  • @PostHog/team-experiments (products/experiments/product.yaml)

Soft owners come from each directory's owners.yaml and each product's product.yaml (resolved nearest-file-wins). For a skipped owner, the locator is the file that decided it. Generated files and lockfiles are ignored when deciding ownership.

stamphog[bot]

This comment was marked as outdated.

@stamphog
stamphog Bot dismissed their stale review September 30, 2026 03:40

A new stamphog review started for this PR — the fresh verdict replaces this approval.

stamphog[bot]

This comment was marked as outdated.

the shares can't be read (the caller then falls back to plain sampling; the scan itself stays
the loud path for an unresolvable experiment).
"""
experiment_id = (scope or {}).get("experiment_id")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This turns on balancing for any scanner that watches an experiment, including the old types that target one through the column. Those scanners have no balance_variants key, so they'd switch to balanced sampling on deploy. Should this only apply to scanner_type == EXPERIMENT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4ffdce: the plan is gated on scanner_type == EXPERIMENT; column-targeted scanners keep plain sampling, with a test.

from products.experiments.backend.facade.replay import variant_rollout_shares # noqa: PLC0415

try:
shares = variant_rollout_shares(team, experiment_id=experiment_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan said to use rollout shares here, but I think that was a mistake on my part. The sessions we sample come from the whole experiment window, so if the rollout changed partway through, the actual mix can be very different. For example, after a change from 90/10 to 10/90, a 10% rate ends up scanning about 46% of sessions, and the estimate never sees it. A variant ramped down to 0% also gets sampled at 100%. Counting exposures per variant from the exposure query would fix both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f4ffdce: shares now come from counting exposed persons per watched variant over the window (an aggregate over exposed_persons_select, metered as the scanner's reads, 60s budget, failing open to plain sampling). A ramped-down variant keeps its real weight, and the rates match the mix actually being sampled. Watched variants with no exposures zero-fill.

if experiment_id is None:
return None
config = scanner_config if isinstance(scanner_config, dict) else {}
if config.get("balance_variants") is False:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turning balance_variants on or off doesn't mark the estimate as stale, and the scout cost check misses it too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With exposure-count shares this turned out to be a non-issue: redistribution spends exactly the configured budget (effective_rate ≡ rate, cap or no cap — new invariant test in f4ffdce), so toggling balance_variants changes how the budget is spread, never the total. The estimate and the scout's cost projection don't move, so there's nothing to invalidate; the estimate now projects with the plain rate again.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a test that the estimate comes out the same with balancing on and off (until a cap kicks in)? Right now only effective_rate is tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f4ffdce: test_balancing_never_changes_the_projected_volume asserts the projection is identical with balancing on and off, including when a cap binds — redistribution preserves the total, so the parenthetical never arrives.

@ksvat
ksvat force-pushed the posthog/rv-experiment-balanced-sampling branch from 3ff4959 to 9cb9886 Compare September 30, 2026 10:07
@stamphog
stamphog Bot dismissed their stale review September 30, 2026 10:07

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@github-actions
github-actions Bot requested a deployment to preview-pr-108868 September 30, 2026 10:07 In progress
stamphog[bot]

This comment was marked as outdated.

Comment thread products/replay_vision/backend/queries/variant_sampling.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
products/replay_vision/backend/queries/variant_sampling.py-154-159 (1)

154-159: 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟡 Minor | ⚡ Quick win

Authorization Bypass

Reachability: Internal
Exploitability: Difficult
CWE: CWE-863 — Incorrect Authorization

Authorize the exposure-count query with the scanner principal.

_variant_exposure_counts reads experiment exposure counts without a principal or validate_experiment_exposure_access. A scanner creator who loses experiment access can therefore trigger an internal unauthorized count read before candidate dispatch is rejected.

The rates do not reach unauthorized observation readers. Candidate queries run as the scanner or backfill creator, and observation reads enforce both current scanner experiment access and the experiment recorded in each snapshot.

Pass scanner.created_by or backfill.created_by through variant_sampling_plan_for_scope. Validate that principal before resolving the linkage, and return None when the principal is missing or denied.

🧹 Nitpick comments (1)
products/replay_vision/backend/tests/test_variant_sampling.py (1)

85-88: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the local imports to module scope.

ScannerVolumeEstimate, project_monthly_observations, and variant_sampling_plan_for_scope are imported inside test methods. Line 12 already imports from variant_sampling at module level, so an import cycle does not explain these local imports.

As per coding guidelines: "Always place imports at the top of the file (module level), never inside functions or methods (local imports)."

Also applies to: 122-122

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 78228884-9923-4aa0-9ae9-52a6b9d3ed6c

📥 Commits

Reviewing files that changed from the base of the PR and between 3ff4959 and f4ffdce.

⛔ Files ignored due to path filters (1)
  • products/replay_vision/frontend/generated/api.schemas.ts is excluded by !**/generated/**
📒 Files selected for processing (7)
  • products/experiments/backend/facade/replay.py
  • products/replay_vision/backend/queries/scanner_volume_estimate.py
  • products/replay_vision/backend/queries/variant_sampling.py
  • products/replay_vision/backend/temporal/activities/backfill.py
  • products/replay_vision/backend/temporal/activities/find_scanner_candidates.py
  • products/replay_vision/backend/tests/test_scanner_candidate_query.py
  • products/replay_vision/backend/tests/test_variant_sampling.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

@stamphog
stamphog Bot dismissed their stale review September 30, 2026 10:26

A new stamphog review started for this PR — the fresh verdict replaces this approval.

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

Contained change inside the flag-gated replay-vision product. The access-control concern was fixed, with the exposure-count query now running the experiment access check. The reviewer threads I read have replies showing they were addressed, and new tests cover the sampling logic and the query path. The backfill total-count query may not use the per-variant rates, which could skew progress and credit estimates slightly. That is minor and not a blocker.

  • Author wrote 0% of the modified lines and has 34 merged PRs in these paths (familiarity MODERATE).
  • Minor: the backfill total-count query may not use the balanced per-variant rates, so the stored total count could differ slightly from the sessions actually walked (CodeRabbit raised this). Consider a follow-up.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 417L, 16F substantive, 749L/22F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (749L, 22F, cross-cutting, feat)
stamphog 2.3.1 .stamphog/policy.yml @ d5f273a · reviewed head d5f273a

Balanced per-variant sampling for experiment scanners, default on: each
sweep computes one salted-hash rate per watched variant from the flag's
live rollout shares (r / (k · share), capped at 1, with a capped
variant's unspent budget redistributed), so an uneven rollout no longer
starves the small arm. The exposure join projects the attributed variant
when balancing is on, backfills share the same query class and salt, the
volume estimate projects with the plan's effective rate, and each
observation's snapshot records the rates it was sampled at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c2db2a38-d03f-4578-af4a-40f61dc700a4
…variant scope

Two review findings: a paused scanner (rate 0) now yields all-zero
per-variant rates instead of sampling a zero-share variant whole, and a
legacy column scope's singular `variant` counts as the one watched arm,
so single-arm targeting gets no balancing plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c2db2a38-d03f-4578-af4a-40f61dc700a4
Review cleanup: the balanced-sampling ClickHouse test imported User,
Experiment, and FeatureFlag inside the test body; a test file has no
import cycle to break, so they belong in the import section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c2db2a38-d03f-4578-af4a-40f61dc700a4
…iment type only

Per review: the plan's shares now come from counting the exposed persons
per watched variant over the experiment window (an aggregate over the
exposure select, metered as the scanner's reads, failing open to plain
sampling), because the flag's rollout percentages diverge from the
window's real mix whenever the rollout changed mid-experiment. The plan
is also gated to the experiment scanner type, so legacy column-targeted
scanners keep plain sampling on deploy. The volume estimate goes back to
projecting with the plain rate: redistribution spends exactly the
configured budget (effective_rate always equals the rate), so balancing
never moves the projection — the invariant now has its own test, which
also answers why toggling balance_variants needs no estimate or scout
cost invalidation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c2db2a38-d03f-4578-af4a-40f61dc700a4
…uery-kind literal

The counts query now runs the experiment's object-level access check as
the scanner's creator (backfills: the launcher), the same principal the
candidate query authorizes, failing open to plain sampling when denied
or missing. Also swaps the malformed-query sweep test's payload off a
real product query kind: the activity's module now reaches a HogQL
dispatcher through the sampling plan, so the repo's model-crossing guard
read the old TrendsQuery literal as this test driving another product's
runner and cancelled Backend CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: c2db2a38-d03f-4578-af4a-40f61dc700a4

This branch was successfully deployed

1 active deployment
preview-pr-108868 — 6b49f8c6 Deployed Sep 30, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stamphog Request AI approval (no full review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants