diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml index 06faf1db67b0..cc182e96a2f9 100644 --- a/.depot/workflows/ci-backend.yml +++ b/.depot/workflows/ci-backend.yml @@ -60,6 +60,11 @@ # shards fall back to the union .test_durations # - actions/cache reads/writes route to Depot Cache, not GitHub Actions Cache, # so shared keys with canonical (schema, uv, pnpm) do not collide +# - hypothesis constants cache: the restore and save steps mirror canonical verbatim, but the +# save only fires on master test-matrix runs and the shadow skips those, so the Depot Cache +# entry only exists after a master workflow_dispatch. Sampled PR shards run cold (they rebuild +# the pool, as canonical did before this cache existed) until then: bounded, documented, and +# harmless because the shadow never gates merges. # - GITHUB_TOKEN / OTEL_SERVICE_NAME neutralized (ambient on Depot, not on GHA) # - Three data-modeling tests deselected (Depot-only MinIO 403 quarantine) # - COMPOSE_PROJECT_NAME pinned to posthog because bin/wait-for-docker filters by that @@ -135,6 +140,10 @@ env: # and HEAD (push) key computations below can't drift. # ci-e2e-playwright.yml, ci-dagster.yml, ci-mcp.yml and ci-rust-flags-integration.yml restore by the same key; keep their copies in sync when bumping. SCHEMA_CACHE_EPOCH: v2 + # Hypothesis constants cache epoch. Bump to abandon every shared entry at once + # (key is posthog-hypothesis-constants----); used by + # the Django test shards below. + HYPOTHESIS_CONSTANTS_EPOCH: v1 SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only COMPOSE_PROJECT_NAME: posthog DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' @@ -2005,6 +2014,37 @@ jobs: key: posthog-segment-durations-${{ github.run_id }} restore-keys: | posthog-segment-durations- + - name: Compute hypothesis constants cache key + # hypothesis builds its constants pool of property-test inputs by + # AST-parsing every local module in sys.modules. It caches the result + # in .hypothesis/constants under a hash of each source file, so the + # pool is content-addressed: restoring a stale copy is safe, because a + # changed file reads as a miss and rebuilds. CI otherwise pays the full + # parse on every shard's pytest collection (about 10 s per shard). + id: hyp-constants-key + shell: bash + run: | + # The key includes the installed hypothesis version because the + # entry format is an implementation detail, and rotates weekly so + # entries for deleted or rewritten files expire. The restore prefix + # covers the gap until the first master run of a new week saves. + hyp_version=$(python -c "import hypothesis; print(hypothesis.__version__)") + prefix="posthog-hypothesis-constants-${hyp_version}-${HYPOTHESIS_CONSTANTS_EPOCH}-" + { + echo "key=${prefix}$(date -u +%G-%V)" + echo "restore_prefix=${prefix}" + } >> "$GITHUB_OUTPUT" + - name: Restore hypothesis constants cache + id: hyp-constants + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + # Constants only. Do NOT add .hypothesis/examples: the example + # database decides which inputs property tests replay, so reusing + # one across runs changes what the tests check. + path: .hypothesis/constants + key: ${{ steps.hyp-constants-key.outputs.key }} + restore-keys: | + ${{ steps.hyp-constants-key.outputs.restore_prefix }} # Tests - name: Set snapshot update flags if: ${{ needs.changes.outputs.backend == 'true' && (needs.detect-snapshot-mode.outputs.mode == 'update' || matrix.person-on-events) }} @@ -2261,6 +2301,27 @@ jobs: else exit $exit_code fi + - name: Save hypothesis constants cache + # One Core shard per master run writes the weekly entry: a save from + # every shard would race on the same key and churn the shared cache + # budget. cache-hit != 'true' skips the save once this week's entry + # exists, which keeps later master runs cheap. + # continue-on-error: overlapping master runs at week rollover fail its + # reservation check when an earlier run has saved the same key, and a + # cache miss must never red a green run. + continue-on-error: true + if: | + github.ref == 'refs/heads/master' && + matrix.segment == 'Core' && + matrix.group == 1 && + !matrix.person-on-events && + !matrix.new-events-schema && + !matrix.compat && + steps.hyp-constants.outputs.cache-hit != 'true' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: .hypothesis/constants + key: ${{ steps.hyp-constants-key.outputs.key }} # Post tests - name: Show docker compose logs on failure if: failure() && (needs.changes.outputs.backend == 'true' && steps.run-core-tests.outcome != 'failure' && steps.run-temporal-tests.outcome != 'failure') diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index c457c08bc257..debe189fa398 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -46,6 +46,10 @@ env: # between the merge-base (PR) and HEAD (push) key computations below. # ci-e2e-playwright.yml, ci-dagster.yml, ci-mcp.yml and ci-rust-flags-integration.yml restore by the same key; keep their copies in sync when bumping. SCHEMA_CACHE_EPOCH: v2 + # Hypothesis constants cache epoch. Bump to abandon every shared entry at once + # (key is posthog-hypothesis-constants----); used by + # the Django test shards below. + HYPOTHESIS_CONSTANTS_EPOCH: v1 SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da' # unsafe - for testing only DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog' REDIS_URL: 'redis://localhost' @@ -2970,6 +2974,39 @@ jobs: restore-keys: | posthog-segment-durations- + - name: Compute hypothesis constants cache key + # hypothesis builds its constants pool of property-test inputs by + # AST-parsing every local module in sys.modules. It caches the result + # in .hypothesis/constants under a hash of each source file, so the + # pool is content-addressed: restoring a stale copy is safe, because a + # changed file reads as a miss and rebuilds. CI otherwise pays the full + # parse on every shard's pytest collection (about 10 s per shard). + id: hyp-constants-key + shell: bash + run: | + # The key includes the installed hypothesis version because the + # entry format is an implementation detail, and rotates weekly so + # entries for deleted or rewritten files expire. The restore prefix + # covers the gap until the first master run of a new week saves. + hyp_version=$(python -c "import hypothesis; print(hypothesis.__version__)") + prefix="posthog-hypothesis-constants-${hyp_version}-${HYPOTHESIS_CONSTANTS_EPOCH}-" + { + echo "key=${prefix}$(date -u +%G-%V)" + echo "restore_prefix=${prefix}" + } >> "$GITHUB_OUTPUT" + + - name: Restore hypothesis constants cache + id: hyp-constants + uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + # Constants only. Do NOT add .hypothesis/examples: the example + # database decides which inputs property tests replay, so reusing + # one across runs changes what the tests check. + path: .hypothesis/constants + key: ${{ steps.hyp-constants-key.outputs.key }} + restore-keys: | + ${{ steps.hyp-constants-key.outputs.restore_prefix }} + - name: Download the run's sharding plan snapshot # Pin every attempt to the plan turbo-discover snapshotted (rationale on # that step); a missing snapshot falls back to the floating caches above. @@ -3326,6 +3363,28 @@ jobs: exit $exit_code fi + - name: Save hypothesis constants cache + # One Core shard per master run writes the weekly entry: a save from + # every shard would race on the same key and churn the shared cache + # budget. cache-hit != 'true' skips the save once this week's entry + # exists, which keeps later master runs cheap. + # continue-on-error: overlapping master runs at week rollover fail its + # reservation check when an earlier run has saved the same key, and a + # cache miss must never red a green run. + continue-on-error: true + if: | + github.ref == 'refs/heads/master' && + matrix.segment == 'Core' && + matrix.group == 1 && + !matrix.person-on-events && + !matrix.new-events-schema && + !matrix.compat && + steps.hyp-constants.outputs.cache-hit != 'true' + uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: .hypothesis/constants + key: ${{ steps.hyp-constants-key.outputs.key }} + # Post tests - name: Show docker compose logs on failure if: failure() && (needs.changes.outputs.backend == 'true' && steps.run-core-tests.outcome != 'failure' && steps.run-temporal-tests.outcome != 'failure') diff --git a/ee/hogai/context/entity_search/context.py b/ee/hogai/context/entity_search/context.py index 079c6a3e71d6..94e448d9d3cf 100644 --- a/ee/hogai/context/entity_search/context.py +++ b/ee/hogai/context/entity_search/context.py @@ -395,8 +395,8 @@ async def list_feature_flags( def _list_feature_flags_sync( self, limit: int = 100, offset: int = 0, active_filter: str | None = None ) -> tuple[list[dict[str, Any]], int]: - # Resource-level gate: filter_queryset_by_access_level only prunes object-level denials, so a - # role without feature flag access would still see flags here (also reachable via list_data). + # Stricter than filter_queryset_by_access_level's fail-closed baseline: a caller without + # feature flag access gets nothing, not even flags they created (also reachable via list_data). if not self.user_access_control.check_access_level_for_resource("feature_flag", "viewer"): return [], 0 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13b19c69688c..6c1bd0a7cc16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,8 +97,8 @@ catalogs: specifier: ^0.57.0 version: 0.57.0 posthog-js: - specifier: ^1.418.17 - version: 1.418.17 + specifier: ^1.420.0 + version: 1.420.0 query-selector-shadow-dom: specifier: ^1.0.0 version: 1.0.1 @@ -415,7 +415,7 @@ importers: version: 3.1.0 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) devDependencies: '@swc/core': specifier: ^1.11.29 @@ -446,7 +446,7 @@ importers: version: 1.5.15 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) devDependencies: '@swc/core': specifier: ^1.11.29 @@ -844,7 +844,7 @@ importers: version: link:../packages/quill/packages/components '@posthog/react': specifier: 'catalog:' - version: 1.10.5(@types/react@18.3.27)(posthog-js@1.418.17)(react@18.3.1) + version: 1.10.5(@types/react@18.3.27)(posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) '@posthog/replay-shared': specifier: workspace:* version: link:../common/replay-shared @@ -1156,7 +1156,7 @@ importers: version: 2.11.0 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) query-selector-shadow-dom: specifier: 'catalog:' version: 1.0.1 @@ -2338,7 +2338,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2405,7 +2405,7 @@ importers: version: 0.1.7 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2469,7 +2469,7 @@ importers: version: 3.0.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2554,7 +2554,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2724,7 +2724,7 @@ importers: version: 3.3.0 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2817,7 +2817,7 @@ importers: version: 0.2.4(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -2856,7 +2856,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3033,7 +3033,7 @@ importers: version: 0.2.4(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3230,7 +3230,7 @@ importers: version: 3.0.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3326,7 +3326,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3474,7 +3474,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3640,7 +3640,7 @@ importers: version: 5.4.1 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3802,7 +3802,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3937,7 +3937,7 @@ importers: version: 0.55.1 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -3989,7 +3989,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4054,7 +4054,7 @@ importers: version: 3.1.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4132,7 +4132,7 @@ importers: version: 4.7.0 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4275,7 +4275,7 @@ importers: version: 2.1.1 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4342,7 +4342,7 @@ importers: version: 0.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@posthog/react': specifier: '*' - version: 1.9.0(@types/react@18.3.27)(posthog-js@1.418.17)(react@18.3.1) + version: 1.9.0(@types/react@18.3.27)(posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) '@storybook/react': specifier: 'catalog:' version: 10.4.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.4.6(@testing-library/dom@10.4.0)(@types/react@18.3.27)(prettier@3.8.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3) @@ -4360,7 +4360,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4433,7 +4433,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4555,7 +4555,7 @@ importers: version: 3.0.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4598,7 +4598,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4620,7 +4620,7 @@ importers: version: 0.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@posthog/react': specifier: '*' - version: 1.9.0(@types/react@18.3.27)(posthog-js@1.418.17)(react@18.3.1) + version: 1.9.0(@types/react@18.3.27)(posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) '@storybook/react': specifier: 'catalog:' version: 10.4.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.4.6(@testing-library/dom@10.4.0)(@types/react@18.3.27)(prettier@3.8.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@6.0.3) @@ -4638,7 +4638,7 @@ importers: version: 3.4.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4688,7 +4688,7 @@ importers: version: 3.0.1(kea@4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1)) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) query-selector-shadow-dom: specifier: 'catalog:' version: 1.0.1 @@ -4719,7 +4719,7 @@ importers: version: 4.0.0-pre.6(patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5)(react@18.3.1) posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -4783,7 +4783,7 @@ importers: version: 5.4.1 posthog-js: specifier: 'catalog:' - version: 1.418.17 + version: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -10213,8 +10213,8 @@ packages: react: optional: true - '@posthog/browser-common@0.5.2': - resolution: {integrity: sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==} + '@posthog/browser-common@0.6.0': + resolution: {integrity: sha512-d6yBE7VeoU3JTpaab3CaCoDCseh0Ytx7sTe0v2ZxhtHNxoKk7rdqr92+bUz9F1T2CuJO5/OTkes4HWT2VHNrTA==} '@posthog/core@1.46.3': resolution: {integrity: sha512-RV5MBx6y9CV/6lIKH/8m9d4fyYfE3D66J2HMAWq/xitZi4r6IZ61B4yMUOfCy/DsQz21LJH/v+jZw681v83hXA==} @@ -10289,8 +10289,8 @@ packages: '@posthog/types@1.405.1': resolution: {integrity: sha512-JvaR4ChUKUk7qSTG58vKN2Br6es9riFF5mvlu7YGcwbB476vfyTO0o/TBh+zE88QhsfxnE5Tr/jKxI+e1v3Q5A==} - '@posthog/types@1.405.3': - resolution: {integrity: sha512-HApKJSYfwo/z2KIVB98E/8XwEL3pTXawBp0AB7FKRVtStz9hizRlaO6n9RR7sjgXLnOLvqj++CGsZb8rQbOS2g==} + '@posthog/types@1.406.2': + resolution: {integrity: sha512-RNNoKD7+ZD2v5uzIDp1SOKK1CseZ+YgNPRYCoPlVKuu+wTB/Tb41JCprMjX9E24gPcsdDg8B47dsEsmO0nwEfw==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -19843,8 +19843,16 @@ packages: posthog-js-lite@4.10.4: resolution: {integrity: sha512-F7iS1s8BWtE9U2bISYkmYB5Xn0Jb61vTZil1OMTOjSJBCAjyMhuv6EFzwN/+r+CS9t7dE1QKudVsXSAEFksr1w==} - posthog-js@1.418.17: - resolution: {integrity: sha512-CP5CxuMcEcJtX4RkBfcwMvrt3jsvyu1UWm2nKCqnlhQp/ejrlz6JADT9vCwq+IZjhC9n4WPiPvmyj//ZrBYZHA==} + posthog-js@1.420.0: + resolution: {integrity: sha512-tIfyJhOCD177n6s5k+0thh7US2x7ZYD0rLJlOIYikDXtjpMIqeSILuZckVedlX9kuZSdpUQvRqoQZpGx4iCqbw==} + peerDependencies: + '@types/react': 18.3.27 + react: 18.3.1 + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true posthog-node@5.51.1: resolution: {integrity: sha512-uEGB5OQvYl9f8Fy2t4FolhcBBlYB8sZM6/4Us517M5dcg/d00yyCusm0HydZFwbPQtPntlpr3gNAmv8TTbWuaA==} @@ -22428,8 +22436,8 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unlayer-types@1.472.0: - resolution: {integrity: sha512-k2Nf8/RaWmzERMMEnfaNNVkiKRz8GlUh1b1O95mUXCfu/+LSSGS8unI2rAqKF/K9qb+1MiuXJLmOC/ItepFrpQ==} + unlayer-types@1.473.0: + resolution: {integrity: sha512-YIrBI/mJeBv4zbMc8KjbIz81CrIl6r1E7W6eE6qaECyrvOPEXC90lIr4U6Sm6pafQp+Y0C+jZmasFQ6eo53SRQ==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -29776,10 +29784,10 @@ snapshots: optionalDependencies: react: 18.3.1 - '@posthog/browser-common@0.5.2': + '@posthog/browser-common@0.6.0': dependencies: '@posthog/core': 1.48.11 - '@posthog/types': 1.405.3 + '@posthog/types': 1.406.2 '@posthog/core@1.46.3': dependencies: @@ -29787,7 +29795,7 @@ snapshots: '@posthog/core@1.48.11': dependencies: - '@posthog/types': 1.405.3 + '@posthog/types': 1.406.2 '@posthog/core@1.48.8': dependencies: @@ -29831,16 +29839,16 @@ snapshots: '@posthog/core': 1.46.3 posthog-node: 5.51.1(rxjs@7.8.1) - '@posthog/react@1.10.5(@types/react@18.3.27)(posthog-js@1.418.17)(react@18.3.1)': + '@posthog/react@1.10.5(@types/react@18.3.27)(posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': dependencies: - posthog-js: 1.418.17 + posthog-js: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: 18.3.1 optionalDependencies: '@types/react': 18.3.27 - '@posthog/react@1.9.0(@types/react@18.3.27)(posthog-js@1.418.17)(react@18.3.1)': + '@posthog/react@1.9.0(@types/react@18.3.27)(posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': dependencies: - posthog-js: 1.418.17 + posthog-js: 1.420.0(@types/react@18.3.27)(react@18.3.1) react: 18.3.1 optionalDependencies: '@types/react': 18.3.27 @@ -29851,7 +29859,7 @@ snapshots: '@posthog/types@1.405.1': {} - '@posthog/types@1.405.3': {} + '@posthog/types@1.406.2': {} '@protobufjs/aspromise@1.1.2': {} @@ -42062,11 +42070,11 @@ snapshots: dependencies: '@posthog/core': 1.48.8 - posthog-js@1.418.17: + posthog-js@1.420.0(@types/react@18.3.27)(react@18.3.1): dependencies: - '@posthog/browser-common': 0.5.2 + '@posthog/browser-common': 0.6.0 '@posthog/core': 1.48.11 - '@posthog/types': 1.405.3 + '@posthog/types': 1.406.2 core-js: 3.49.0 dompurify: 3.4.13 fflate: 0.4.8 @@ -42074,6 +42082,9 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 web-vitals-soft-navs: web-vitals@6.0.0 + optionalDependencies: + '@types/react': 18.3.27 + react: 18.3.1 posthog-node@5.51.1(rxjs@7.8.1): dependencies: @@ -42889,7 +42900,7 @@ snapshots: react-email-editor@1.7.11(react@18.3.1): dependencies: react: 18.3.1 - unlayer-types: 1.472.0 + unlayer-types: 1.473.0 react-grid-layout@2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -45204,7 +45215,7 @@ snapshots: universalify@2.0.1: {} - unlayer-types@1.472.0: {} + unlayer-types@1.473.0: {} unpipe@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eca5df2a3028..d5b18d0c428a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -145,7 +145,7 @@ catalog: '@posthog/icons': ^0.38.0 '@posthog/brand': 0.10.0 '@posthog/react': ^1.10.5 - posthog-js: ^1.418.17 + posthog-js: ^1.420.0 # Build tools '@parcel/packager-ts': 2.16.4 '@parcel/transformer-typescript-types': 2.16.4 diff --git a/posthog/api/comments.py b/posthog/api/comments.py index ae21d735efe8..0f1836d8c115 100644 --- a/posthog/api/comments.py +++ b/posthog/api/comments.py @@ -890,9 +890,8 @@ def _filter_ticket_scoped_queryset(self, queryset: QuerySet, item_id: str | None return queryset.none() return queryset - # filter_queryset_by_access_level trusts the view to have enforced resource-level access - # already, and this view is authorized as `comment` — so a caller denied the ticket resource - # would otherwise get the unfiltered ticket queryset back here. + # Stricter than filter_queryset_by_access_level's fail-closed baseline: a caller denied + # the ticket resource sees no ticket comments at all, not even on tickets they created. if not self.user_access_control.check_access_level_for_resource( "ticket", "viewer" ) and not self.user_access_control.has_any_specific_access_for_resource("ticket", "viewer"): diff --git a/posthog/api/posthog_connection.py b/posthog/api/posthog_connection.py index 8fb6ba3724b6..445e5a76b9f4 100644 --- a/posthog/api/posthog_connection.py +++ b/posthog/api/posthog_connection.py @@ -31,7 +31,13 @@ from rest_framework.throttling import BaseThrottle from posthog.api.routing import TeamAndOrgViewSetMixin -from posthog.auth import OAuthAccessTokenAuthentication, PersonalAPIKeyAuthentication, SessionAuthentication +from posthog.auth import ( + MCP_USER_AGENT_MARKER, + OAuthAccessTokenAuthentication, + PersonalAPIKeyAuthentication, + SessionAuthentication, + is_mcp_request, +) from posthog.models.integration import POSTHOG_CONNECT_KIND, Integration, OauthIntegration, posthog_connect_base_url from posthog.permissions import get_authenticator_scopes from posthog.rate_limit import PostHogConnectionForwardThrottle @@ -152,10 +158,19 @@ def _forward_through_connection( *, query: dict[str, Any] | None = None, data: Any = None, + mcp_origin: bool = False, ) -> ForwardResult: - """Replay one request against the connected project, injecting the connection's token.""" + """Replay one request against the connected project, injecting the connection's token. + + Pass mcp_origin=True to stamp the outbound request with the MCP user agent, so the + target organization applies its MCP read-only policy to a write that an MCP client + started through this connection. A forward that did not start from an MCP request must + not be marked: the target organization restricts MCP, not connections.""" token = _connection_access_token(integration) base = posthog_connect_base_url(integration.config.get("region")) + headers = {"Authorization": f"Bearer {token}", CONNECTION_MARKER_HEADER: "1"} + if mcp_origin: + headers["User-Agent"] = f"posthog-connection; {MCP_USER_AGENT_MARKER}" raw = bytearray() timed_out = False @@ -168,7 +183,7 @@ def _forward_through_connection( f"{base}/{path}", params=query or None, json=data if method in _METHODS_WITH_BODY else None, - headers={"Authorization": f"Bearer {token}", CONNECTION_MARKER_HEADER: "1"}, + headers=headers, timeout=CONNECTION_FORWARD_TIMEOUT_SECONDS, # A compromised/misconfigured target must not be able to 30x us into resending the # bearer token to another origin. @@ -317,6 +332,7 @@ def forward(self, request: Request, pk: str | None = None, **kwargs: Any) -> Res _validate_target_path(payload["path"]), query=payload.get("query"), data=payload.get("data"), + mcp_origin=is_mcp_request(request), ) body = {"status": result.status, "data": result.data} # A failure on this side is mirrored as the outer status too, so a caller that only reads the diff --git a/posthog/api/routing.py b/posthog/api/routing.py index 7dc7524ec9cb..13cebe1da88a 100644 --- a/posthog/api/routing.py +++ b/posthog/api/routing.py @@ -34,6 +34,7 @@ from posthog.permissions import ( AccessControlPermission, APIScopePermission, + MCPAccessPermission, OrganizationMemberPermissions, SharingTokenPermission, TeamMemberAccessPermission, @@ -255,9 +256,9 @@ def get_permissions(self): except NotImplementedError: pass else: - # Domain enforcement is a tenant boundary, not an authorization level: views that - # shape their own permission chain cannot opt out of it. - return [*dangerously_defined, VerifiedDomainEnforcementPermission()] + # Domain enforcement and the MCP cap are tenant boundaries, not authorization + # levels. Views that shape their own permission chain cannot remove them. + return [*dangerously_defined, VerifiedDomainEnforcementPermission(), MCPAccessPermission()] if isinstance(self.request.successful_authenticator, InternalAPIAuthentication): return [IsAuthenticated()] @@ -270,7 +271,11 @@ def get_permissions(self): # NOTE: We define these here to make it hard _not_ to use them. If you want to override them, you have to # override the entire method. - permission_classes: list = [IsAuthenticated, APIScopePermission, AccessControlPermission] + permission_classes: list = [ + IsAuthenticated, + APIScopePermission, + AccessControlPermission, + ] if self._is_team_view or self._is_project_view: permission_classes.append(TeamMemberAccessPermission) @@ -278,8 +283,10 @@ def get_permissions(self): permission_classes.append(OrganizationMemberPermissions) # After the membership permission, so non-members get the generic denial and the - # organization row it resolved is reused. + # organization row it resolved is reused. The MCP cap follows for the same reason: + # its message must not disclose another organization's security settings. permission_classes.append(VerifiedDomainEnforcementPermission) + permission_classes.append(MCPAccessPermission) permission_classes.extend(self.permission_classes) return [permission() for permission in permission_classes] diff --git a/posthog/api/search.py b/posthog/api/search.py index f735a8517485..5913fe73655a 100644 --- a/posthog/api/search.py +++ b/posthog/api/search.py @@ -124,9 +124,9 @@ class SearchViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet): @extend_schema( parameters=[QuerySerializer], description=( - "Full-text search across project entities. Each result includes `user_access_level`, " - "the requesting user's resolved access level for that object (`none` means the user " - "cannot open it); `null` when access controls don't apply to the entity type." + "Full-text search across project entities. Objects the user cannot access are left out. " + "Each result includes `user_access_level`, the requesting user's resolved access level for " + "that object; `null` when access controls don't apply to the entity type." ), ) def list(self, request: Request, **kw) -> HttpResponse: diff --git a/posthog/api/test/__snapshots__/test_element.ambr b/posthog/api/test/__snapshots__/test_element.ambr index 6dd8443d3c72..4fa815d7e71b 100644 --- a/posthog/api/test/__snapshots__/test_element.ambr +++ b/posthog/api/test/__snapshots__/test_element.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -366,6 +367,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -418,6 +420,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -497,6 +500,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/api/test/__snapshots__/test_preflight.ambr b/posthog/api/test/__snapshots__/test_preflight.ambr index 245ac729815f..57fd6d7c4eb7 100644 --- a/posthog/api/test/__snapshots__/test_preflight.ambr +++ b/posthog/api/test/__snapshots__/test_preflight.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr index 65c00a119c22..a5d770de4603 100644 --- a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr +++ b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -515,6 +516,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -615,6 +617,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -755,6 +758,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -808,6 +812,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -887,6 +892,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1064,6 +1070,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2435,6 +2442,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2581,6 +2589,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2769,6 +2778,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3129,6 +3139,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3229,6 +3240,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3369,6 +3381,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3422,6 +3435,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3501,6 +3515,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3695,6 +3710,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3801,6 +3817,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3971,6 +3988,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4217,6 +4235,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4548,6 +4567,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4688,6 +4708,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4741,6 +4762,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4820,6 +4842,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5128,6 +5151,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5454,6 +5478,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5594,6 +5619,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5647,6 +5673,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5742,6 +5769,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7728,6 +7756,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7828,6 +7857,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7968,6 +7998,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8021,6 +8052,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8100,6 +8132,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8190,6 +8223,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8926,6 +8960,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9066,6 +9101,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9119,6 +9155,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9198,6 +9235,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9272,6 +9310,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9552,6 +9591,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9884,6 +9924,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10024,6 +10065,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10077,6 +10119,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10165,6 +10208,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10470,6 +10514,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10891,6 +10936,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11031,6 +11077,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11084,6 +11131,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11163,6 +11211,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12294,6 +12343,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12434,6 +12484,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12487,6 +12538,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12566,6 +12618,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12782,6 +12835,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13430,6 +13484,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13530,6 +13585,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13670,6 +13726,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13723,6 +13780,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13802,6 +13860,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13892,6 +13951,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14628,6 +14688,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14768,6 +14829,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14821,6 +14883,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14900,6 +14963,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14974,6 +15038,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr index 9b2a64ba6ae8..df2b9e66ac73 100644 --- a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr +++ b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -159,6 +160,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -299,6 +301,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -366,6 +369,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -445,6 +449,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -843,6 +848,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -991,6 +997,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1260,6 +1267,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1400,6 +1408,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1467,6 +1476,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1546,6 +1556,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1669,6 +1680,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1748,6 +1760,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/api/test/test_posthog_connection.py b/posthog/api/test/test_posthog_connection.py index ecc585f5de5b..c0b3cd4ca41d 100644 --- a/posthog/api/test/test_posthog_connection.py +++ b/posthog/api/test/test_posthog_connection.py @@ -75,6 +75,34 @@ def test_forward_injects_token_and_passes_through(self, client: HttpClient): assert mock_request.call_args[1]["params"] == {"limit": "5"} assert mock_request.call_args[1]["allow_redirects"] is False + def test_forward_marks_the_target_as_mcp_only_for_mcp_origin(self, client: HttpClient): + from posthog.auth import MCP_USER_AGENT_MARKER + from posthog.models.personal_api_key import PersonalAPIKey + from posthog.models.utils import generate_random_token_personal, hash_key_value + + key_value = generate_random_token_personal() + PersonalAPIKey.objects.create(label="mcp", user=self.user, secure_value=hash_key_value(key_value), scopes=["*"]) + with patch(FORWARD_PATH) as mock_request: + mock_request.return_value = _mock_response(201, {"id": "abc"}) + client.post( + self._forward_url(), + {"method": "POST", "path": "api/projects/2/tasks/", "data": {"description": "hi"}}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"}, + ) + assert MCP_USER_AGENT_MARKER in mock_request.call_args[1]["headers"]["User-Agent"] + + with patch(FORWARD_PATH) as mock_request: + mock_request.return_value = _mock_response(201, {"id": "abc"}) + client.force_login(self.user) + client.post( + self._forward_url(), + {"method": "POST", "path": "api/projects/2/tasks/", "data": {"description": "hi"}}, + content_type="application/json", + ) + assert "User-Agent" not in mock_request.call_args[1]["headers"] + def test_forward_sends_body_only_for_write_methods(self, client: HttpClient): client.force_login(self.user) with patch(FORWARD_PATH) as mock_request: diff --git a/posthog/api/test/test_search.py b/posthog/api/test/test_search.py index 4c64b6947d39..afb89210dac5 100644 --- a/posthog/api/test/test_search.py +++ b/posthog/api/test/test_search.py @@ -400,18 +400,16 @@ def _levels_by_result_id(self, response, entity_type: str) -> dict[str, str | No if result["type"] == entity_type } - def test_blocked_resource_results_are_annotated_none(self): + def test_blocked_resource_results_are_hidden_except_own(self): FeatureFlag.objects.create(key="searchable-a", team=self.team, created_by=self.other_user) - FeatureFlag.objects.create(key="searchable-b", team=self.team, created_by=self.other_user) + own = FeatureFlag.objects.create(key="searchable-b", team=self.team, created_by=self.user) EventDefinition.objects.create(team=self.team, name="searchable-event") AccessControl.objects.create(team=self.team, resource="feature_flag", resource_id=None, access_level="none") response = self.client.get("/api/projects/@current/search?q=searchable") assert response.status_code == 200 - flag_levels = self._levels_by_result_id(response, "feature_flag") - assert len(flag_levels) == 2 - assert set(flag_levels.values()) == {"none"} + assert set(self._levels_by_result_id(response, "feature_flag")) == {str(own.id)} assert set(self._levels_by_result_id(response, "event_definition").values()) == {None} def test_object_grants_resolve_by_pk_for_short_id_entities(self): diff --git a/posthog/auth.py b/posthog/auth.py index ed710b05ce8f..98fb8e9f2df1 100644 --- a/posthog/auth.py +++ b/posthog/auth.py @@ -1475,3 +1475,27 @@ def authenticate(self, request: Request) -> tuple[AnonymousUser, Any] | None: def authenticate_header(self, request: Request) -> str: return "WebhookSignature" + + +# services/mcp sends this user agent on its API calls (USER_AGENT in its +# oauth-constants.ts). The two runtimes cannot share one constant, so this value +# mirrors that one. If they diverge, this check stops matching MCP traffic and the +# read-only policy stops applying. A client controls its own user agent. The match applies MCP +# policy to the normal MCP pathway only. It does not stop a hostile key holder. +# The same credential keeps its full scopes under a different user agent. A future +# change can reduce the credential's scopes when the token is created. +MCP_USER_AGENT_MARKER = "posthog/mcp-server" + + +def is_mcp_request(request: Union[HttpRequest, Request]) -> bool: + """Returns True when a token-authenticated request comes through the MCP server.""" + authenticator = getattr(request, "successful_authenticator", None) + # Every user-delegated scoped-token type the MCP server can authenticate with. ID-JAG + # (XAA) tokens are served from the same OAuth token endpoint and carry scopes, so a + # write on that pathway must be classified as MCP like a personal key or OAuth token. + if isinstance( + authenticator, + PersonalAPIKeyAuthentication | OAuthAccessTokenAuthentication | IDJagAccessTokenAuthentication, + ): + return MCP_USER_AGENT_MARKER in (request.headers.get("User-Agent") or "") + return False diff --git a/posthog/management/migration_analysis/hot_table_acknowledged_migrations.txt b/posthog/management/migration_analysis/hot_table_acknowledged_migrations.txt index 91f021b57389..c8220a239416 100644 --- a/posthog/management/migration_analysis/hot_table_acknowledged_migrations.txt +++ b/posthog/management/migration_analysis/hot_table_acknowledged_migrations.txt @@ -21,3 +21,4 @@ posthog.1262_organization_members_can_see_org_members posthog.1272_user_ui_configuration posthog.1284_organization_enforce_verified_domains posthog.1304_organization_has_active_subscription +posthog.1321_organization_read_only_mcp_access diff --git a/posthog/migrations/1321_organization_read_only_mcp_access.py b/posthog/migrations/1321_organization_read_only_mcp_access.py new file mode 100644 index 000000000000..bf169695b60a --- /dev/null +++ b/posthog/migrations/1321_organization_read_only_mcp_access.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.17 on 2026-08-25 10:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("posthog", "1320_remove_oauth_scope_trgm")] + + operations = [ + migrations.AddField( + model_name="organization", + name="read_only_mcp_access", + field=models.BooleanField( + blank=True, + default=False, + help_text="When True, requests through the PostHog MCP server can read but not change this organization's data.", + null=True, + ), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index b16fcf150e55..220d0d643fe7 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -1320_remove_oauth_scope_trgm +1321_organization_read_only_mcp_access diff --git a/posthog/models/organization.py b/posthog/models/organization.py index cb8a31024665..e5aae4f94e23 100644 --- a/posthog/models/organization.py +++ b/posthog/models/organization.py @@ -279,6 +279,12 @@ class DeactivationReason(models.TextChoices): help_text="When False, members (below admin) only see themselves in the members list and only project members in access control.", ) allow_publicly_shared_resources = models.BooleanField(default=True) + read_only_mcp_access = models.BooleanField( + default=False, + null=True, + blank=True, + help_text="When True, requests through the PostHog MCP server can read but not change this organization's data.", + ) default_role = models.ForeignKey( "ee.Role", on_delete=models.SET_NULL, diff --git a/posthog/permissions.py b/posthog/permissions.py index 23a2aeefc7fe..7ed4a71d72f6 100644 --- a/posthog/permissions.py +++ b/posthog/permissions.py @@ -25,6 +25,7 @@ SharingAccessTokenAuthentication, SharingPasswordProtectedAuthentication, TeamSecretTokenAuthentication, + is_mcp_request, ) from posthog.cloud_utils import is_cloud from posthog.constants import AvailableFeature @@ -42,6 +43,7 @@ from posthog.session.reauth import sensitive_action_reference, step_up_required from posthog.utils import get_can_create_org +from products.access_control.backend.facade.mcp_access import mcp_access_denial from products.access_control.backend.facade.user_access_control import ( AccessControlLevel, UserAccessControl, @@ -862,6 +864,76 @@ def _check_organization_personal_api_key_restrictions(self, request, view) -> No return +# Standard detail actions fetch their object by URL pk, so has_object_permission can resolve the +class MCPAccessPermission(ScopeBasePermission): + """Denies write actions through the MCP server when the organization restricts it. + + This class is an independent vote in the permission stack. DRF combines permission + classes with AND semantics, so a `*`-scoped token that passes `APIScopePermission` + is still capped here. The stack runs this class after the membership permissions, + so non-members get the generic denial. This class subclasses ScopeBasePermission + only for `_get_required_scopes`. It derives an action's read or write nature the + same way `APIScopePermission` does.""" + + def has_permission(self, request, view) -> bool: + # Cheap exit first. Almost every request is not MCP. The check is two isinstance + # checks and one header read, with no query. + if not is_mcp_request(request): + return True + + # Root viewsets (organizations, projects, environments) carry no parent URL kwargs, + # and `get_organization_from_view` falls back to the user's current organization + # there, which is a UI preference, not the request's target. When an object exists, + # delegate to has_object_permission, which resolves the target organization from the + # fetched object and caps the write there — the same split OrganizationMemberPermissions + # uses. A create has no object and lands in the resolved organization (what the + # serializer's create uses), so the current-organization resolution is correct for it; + # a list is a read and passes _admits either way. Views deriving their target from the + # current team are also fine by construction. + target_in_url = bool(view.parent_query_kwargs) or bool(view.param_derived_from_user_current_team) + if not target_in_url and getattr(view, "action", None) not in ["list", "create"]: + return True + + return self._admits(request, view, self._target_organization(view)) + + def has_object_permission(self, request, view, object) -> bool: + if not is_mcp_request(request): + return True + if isinstance(object, Organization): + return self._admits(request, view, object) + organization = getattr(object, "organization", None) + if isinstance(organization, Organization): + return self._admits(request, view, organization) + return True + + @staticmethod + def _target_organization(view) -> Optional[Organization]: + if getattr(view, "scope_object", None) is None: + return None + try: + return get_organization_from_view(view) + except (ValueError, NotFound): + return None + + def _admits(self, request, view, organization: Optional[Organization]) -> bool: + if organization is None: + return True + required_scopes = self._get_required_scopes(request, view) + if required_scopes is None: + # This action is unclassified: no required_scopes, or an INTERNAL scope object. + # On the default stack, APIScopePermission already rejects token auth for these. + # A dangerously_get_permissions chain can omit APIScopePermission. Fall back to + # the HTTP method there and treat every non-safe method as a write. + writes = request.method not in SAFE_METHODS + else: + writes = any(scope.endswith(":write") for scope in required_scopes) + denial = mcp_access_denial(organization, is_mcp=True, writes=writes) + if denial is not None: + self.message = denial + return False + return True + + class AccessControlPermission(ScopeBasePermission): """ Unified permissions access - controls access to any object based on the user's access controls diff --git a/posthog/session_recordings/test/__snapshots__/test_session_recording_playlist.ambr b/posthog/session_recordings/test/__snapshots__/test_session_recording_playlist.ambr index 782f1e8de3be..bc3573674767 100644 --- a/posthog/session_recordings/test/__snapshots__/test_session_recording_playlist.ambr +++ b/posthog/session_recordings/test/__snapshots__/test_session_recording_playlist.ambr @@ -366,6 +366,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -506,6 +507,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -559,6 +561,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -638,6 +641,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1492,6 +1496,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1632,6 +1637,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1685,6 +1691,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1757,6 +1764,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1809,6 +1817,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2261,6 +2270,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2314,6 +2324,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2393,6 +2404,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr index 6f0e310de8d3..0091fbc77ea0 100644 --- a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr +++ b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr @@ -76,6 +76,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -422,6 +423,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -475,6 +477,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -554,6 +557,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -606,6 +610,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -913,6 +918,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1256,6 +1262,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1309,6 +1316,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1388,6 +1396,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1440,6 +1449,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1747,6 +1757,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2087,6 +2098,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2140,6 +2152,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2219,6 +2232,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2271,6 +2285,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2578,6 +2593,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2918,6 +2934,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -2971,6 +2988,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3050,6 +3068,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3102,6 +3121,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3409,6 +3429,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3752,6 +3773,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3805,6 +3827,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3884,6 +3907,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -3936,6 +3960,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4243,6 +4268,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4586,6 +4612,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4639,6 +4666,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4718,6 +4746,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -4770,6 +4799,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5077,6 +5107,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5420,6 +5451,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5473,6 +5505,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5552,6 +5585,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5604,6 +5638,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -5911,6 +5946,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -6512,6 +6548,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -6652,6 +6689,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -6705,6 +6743,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -6784,6 +6823,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -6836,6 +6876,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7367,6 +7408,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7507,6 +7549,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7560,6 +7603,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7685,6 +7729,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -7737,6 +7782,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8188,6 +8234,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8297,6 +8344,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8437,6 +8485,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8490,6 +8539,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8569,6 +8619,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8621,6 +8672,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -8964,6 +9016,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9280,6 +9333,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9420,6 +9474,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9473,6 +9528,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9527,6 +9583,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9606,6 +9663,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -9658,6 +9716,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10165,6 +10224,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10217,6 +10277,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10359,6 +10420,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10859,6 +10921,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -10906,6 +10969,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11046,6 +11110,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11099,6 +11164,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11178,6 +11244,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11230,6 +11297,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11752,6 +11820,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11892,6 +11961,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -11945,6 +12015,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12024,6 +12095,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12076,6 +12148,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12128,6 +12201,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12576,6 +12650,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12682,6 +12757,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12822,6 +12898,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12875,6 +12952,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -12954,6 +13032,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13006,6 +13085,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13613,6 +13693,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13753,6 +13834,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13903,6 +13985,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -13982,6 +14065,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14034,6 +14118,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14578,6 +14663,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14718,6 +14804,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14771,6 +14858,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14850,6 +14938,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -14902,6 +14991,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/settings/cohorts.py b/posthog/settings/cohorts.py index 37d81106b0cf..5b6291a13379 100644 --- a/posthog/settings/cohorts.py +++ b/posthog/settings/cohorts.py @@ -42,6 +42,25 @@ BEHAVIORAL_BACKFILL_FINALIZER_ENABLED: bool = get_from_env( "BEHAVIORAL_BACKFILL_FINALIZER_ENABLED", False, type_cast=str_to_bool ) +# Which runs the finalizer may stamp. Comma list of run UUIDs; empty / "all" / "*" lifts the +# restriction, "none" matches nothing. Whitespace and case are tolerated and ids compare as parsed +# UUIDs, so a line emitted by `manage_cohort_backfill_runs inventory` pastes in verbatim. +# +# It exists because a readiness stamp is one way. The moment +# `BEHAVIORAL_BACKFILL_FINALIZER_ENABLED` flips, every reconciling run the seeder has observed +# qualifies and gets stamped, and there is no un-stamp — so enabling the finalizer against an +# unaudited backlog is irreversible. This narrows that to the short list of runs an operator +# inspected by hand. +# +# A value meant as a restriction never degrades into "every run": if every token is malformed, the +# parser matches nothing and logs, because widening is the direction that cannot be undone. +# +# The default lifts the restriction on purpose. A fail-closed default would silently park every +# future run the first time someone forgot to widen it, which is the invisible backlog the +# `not_allowlisted` gauge label exists to expose. Safety comes from ordering instead: set this to a +# verified list (or "none") in every region before `BEHAVIORAL_BACKFILL_FINALIZER_ENABLED` is +# turned on, so the default never applies where it matters. +BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST: str = os.getenv("BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST", "all") # Whether the finalizer may terminalize person-property runs and stamp # `last_backfill_person_properties_at`. # diff --git a/posthog/tasks/test/__snapshots__/test_process_scheduled_changes.ambr b/posthog/tasks/test/__snapshots__/test_process_scheduled_changes.ambr index 28ebe8612c06..7c22347d3764 100644 --- a/posthog/tasks/test/__snapshots__/test_process_scheduled_changes.ambr +++ b/posthog/tasks/test/__snapshots__/test_process_scheduled_changes.ambr @@ -472,6 +472,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -758,6 +759,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1407,6 +1409,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1789,6 +1792,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/posthog/temporal/mcp_analytics/intent_clustering/activities.py b/posthog/temporal/mcp_analytics/intent_clustering/activities.py index ed78cb141a15..7670453acd1b 100644 --- a/posthog/temporal/mcp_analytics/intent_clustering/activities.py +++ b/posthog/temporal/mcp_analytics/intent_clustering/activities.py @@ -109,12 +109,49 @@ async def compute_intent_clusters_activity(inputs: IntentClusteringWorkflowInput snapshot = await _mark_computing(team, user) try: - session_ids = await database_sync_to_async(intent_clustering.sample_corpus_sessions)( - team, lookback_days=inputs.lookback_days + # Stratified sampling: bucket intent-bearing sessions per tool + # so every tool keeps a floor, instead of a uniform sample that + # erases low/mid-volume tools (logs/tracing/metrics). The + # uniform sample still fills the rest of the corpus budget, and + # carries it alone when the per-tool buckets can't be fetched, + # so a capture or schema gap never blocks a run. + try: + tools_by_session = await database_sync_to_async(intent_clustering.fetch_tools_by_session)( + team, + lookback_days=inputs.lookback_days, + max_sessions_per_tool=intent_clustering.MIN_SESSIONS_PER_TOOL, + ) + except Exception: + logger.warning( + "mcpa.intent_clustering.tool_buckets_unavailable_falling_back_to_uniform", + team_id=inputs.team_id, + ) + tools_by_session = {} + + uniform_sample = await database_sync_to_async(intent_clustering.sample_corpus_sessions)( + team, lookback_days=inputs.lookback_days, max_sessions=intent_clustering.MAX_CORPUS_SESSIONS + ) + session_ids = intent_clustering.select_corpus_sessions( + tools_by_session, + uniform_sample, + min_sessions_per_tool=intent_clustering.MIN_SESSIONS_PER_TOOL, + max_total_sessions=intent_clustering.MAX_CORPUS_SESSIONS, ) + call_rows = await database_sync_to_async(intent_clustering.fetch_session_calls)( team, session_ids, lookback_days=inputs.lookback_days ) + # Cap dominant tools so they can't occupy the whole intent corpus. + cap_result = intent_clustering.cap_per_tool_call_volume( + call_rows, max_calls_per_tool=intent_clustering.MAX_CALLS_PER_TOOL + ) + call_rows = cap_result.kept_rows + if cap_result.per_tool_report: + logger.info( + "mcpa.intent_clustering.capped_overrepresented_tools", + team_id=inputs.team_id, + per_tool=cap_result.per_tool_report, + ) records, calls_by_session, corpus_stats = intent_clustering.build_call_corpus( call_rows, top_n=inputs.top_n ) diff --git a/posthog/temporal/mcp_analytics/intent_clustering/constants.py b/posthog/temporal/mcp_analytics/intent_clustering/constants.py index 0c6830761102..9abcde1cffbd 100644 --- a/posthog/temporal/mcp_analytics/intent_clustering/constants.py +++ b/posthog/temporal/mcp_analytics/intent_clustering/constants.py @@ -21,7 +21,10 @@ # Sampling ------------------------------------------------------------------ DEFAULT_LOOKBACK_DAYS = 7 -DEFAULT_TOP_N_INTENTS = 500 +# Matches the pipeline default in products/mcp_analytics/backend/intent_clustering.py. +# Raised to 1000 with stratified sampling: per-tool floors + the per-tool call cap +# keep the extra intents spread across tools instead of long-tail exec/scout noise. +DEFAULT_TOP_N_INTENTS = 1000 MIN_INTENTS_FOR_CLUSTERING = 2 # Workflow + activity envelopes -------------------------------------------- diff --git a/posthog/temporal/mcp_analytics/intent_clustering/tests/test_coordinator.py b/posthog/temporal/mcp_analytics/intent_clustering/tests/test_coordinator.py index fdabbc6fb945..152bf69979e2 100644 --- a/posthog/temporal/mcp_analytics/intent_clustering/tests/test_coordinator.py +++ b/posthog/temporal/mcp_analytics/intent_clustering/tests/test_coordinator.py @@ -126,7 +126,7 @@ class TestCoordinatorParseInputs: @pytest.mark.parametrize( "args, expected_lookback, expected_top_n, expected_max_concurrent", [ - ([], 7, 500, 4), + ([], 7, 1000, 4), (["14", "200", "2"], 14, 200, 2), ], ) diff --git a/posthog/temporal/mcp_analytics/intent_clustering/tests/test_workflow.py b/posthog/temporal/mcp_analytics/intent_clustering/tests/test_workflow.py index e23465e0faf0..90971ec53e79 100644 --- a/posthog/temporal/mcp_analytics/intent_clustering/tests/test_workflow.py +++ b/posthog/temporal/mcp_analytics/intent_clustering/tests/test_workflow.py @@ -87,11 +87,11 @@ class TestParseInputs: "raw_payload, expected_team_id, expected_lookback_days, expected_top_n, expected_user_id", [ # Empty input falls back to dataclass defaults. - ([], 0, 7, 500, None), + ([], 0, 7, 1000, None), # Full JSON payload overrides every field. (['{"team_id": 99, "lookback_days": 3, "top_n": 50, "user_id": 5}'], 99, 3, 50, 5), # Partial payload preserves dataclass defaults for omitted fields. - (['{"team_id": 99}'], 99, 7, 500, None), + (['{"team_id": 99}'], 99, 7, 1000, None), ], ) def test_parse_inputs_cases( diff --git a/products/access_control/backend/facade/mcp_access.py b/products/access_control/backend/facade/mcp_access.py new file mode 100644 index 000000000000..a2da54b77a4c --- /dev/null +++ b/products/access_control/backend/facade/mcp_access.py @@ -0,0 +1,30 @@ +"""The org-wide MCP read-only policy. + +`Organization.read_only_mcp_access` caps what any member can do through the PostHog MCP +server. Reads work. Writes are denied. The cap applies to every member, including admins. +Access through the app and direct API use are not affected. + +This module is the decision point. It takes domain facts and returns a verdict. It does +not read requests. `posthog.auth.is_mcp_request` classifies the pathway. Enforcement +points such as `MCPAccessPermission` in posthog/permissions.py gather the facts and +apply the verdict. The access-control facade's `decide()` can call this later, to apply the +same cap to object-level decisions. +""" + +from posthog.constants import AvailableFeature +from posthog.models.organization import Organization + + +def mcp_access_denial(organization: Organization, *, is_mcp: bool, writes: bool) -> str | None: + """Makes the MCP read-only decision. Returns a denial message for the user when the + organization restricts MCP access and the action writes. Returns None to allow.""" + if not writes or not is_mcp: + return None + if not organization.read_only_mcp_access: + return None + if not organization.is_feature_available(AvailableFeature.ORGANIZATION_SECURITY_SETTINGS): + return None + return ( + "Your organization restricts MCP access to read-only. " + "An organization admin can change this in your organization settings." + ) diff --git a/products/access_control/backend/facade/user_access_control.py b/products/access_control/backend/facade/user_access_control.py index db53e205adb5..873f94c6bdb2 100644 --- a/products/access_control/backend/facade/user_access_control.py +++ b/products/access_control/backend/facade/user_access_control.py @@ -1124,9 +1124,10 @@ def filter_queryset_by_access_level( decision = self._blocked_and_allowed_object_ids(access_controls) # Apply filtering logic based on resource-level access - if not self.has_resource_access(resource) and decision.allowed_ids: - # User has "none" resource access but specific object access - # Only show objects they have explicit access to (plus created objects) + if not self.has_resource_access(resource): + # Resource-level "none": show only granted objects and the user's own objects, also + # when there are no grants at all. Logic-layer and background callers reach this + # filter with no permission layer above it, so it must fail closed on its own. if model_has_creator: queryset = queryset.filter(Q(id__in=decision.allowed_ids) | Q(created_by=self._user)) else: diff --git a/products/access_control/backend/tests/test_mcp_access.py b/products/access_control/backend/tests/test_mcp_access.py new file mode 100644 index 000000000000..7a0da2958906 --- /dev/null +++ b/products/access_control/backend/tests/test_mcp_access.py @@ -0,0 +1,169 @@ +from posthog.test.base import APIBaseTest + +from django.http import HttpRequest +from django.test import SimpleTestCase + +from parameterized import parameterized + +from posthog.auth import ( + MCP_USER_AGENT_MARKER, + IDJagAccessTokenAuthentication, + OAuthAccessTokenAuthentication, + PersonalAPIKeyAuthentication, + SessionAuthentication, + is_mcp_request, +) +from posthog.constants import AvailableFeature +from posthog.models.organization import OrganizationMembership +from posthog.models.personal_api_key import PersonalAPIKey +from posthog.models.utils import generate_random_token_personal, hash_key_value + + +class TestMCPReadOnlyEnforcement(APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.organization.available_product_features = [ + { + "key": AvailableFeature.ORGANIZATION_SECURITY_SETTINGS, + "name": AvailableFeature.ORGANIZATION_SECURITY_SETTINGS, + }, + # So the multi-project plan gate passes and MCPAccessPermission is the denier + # on the root-create path, not PremiumMultiProjectPermission. + {"key": AvailableFeature.ORGANIZATIONS_PROJECTS, "name": AvailableFeature.ORGANIZATIONS_PROJECTS}, + ] + self.organization.save() + # Owner, not the default member: the cap binds every level, so the membership + # permissions must pass for MCPAccessPermission to be the one that denies. + self.organization_membership.level = OrganizationMembership.Level.OWNER + self.organization_membership.save() + self.key_value = generate_random_token_personal() + PersonalAPIKey.objects.create( + label="mcp test", + user=self.user, + secure_value=hash_key_value(self.key_value), + scopes=["*"], + ) + self.client.logout() + + def _set_read_only(self, value: bool) -> None: + self.organization.read_only_mcp_access = value + self.organization.save() + + def _request(self, method: str, body: dict | None = None, mcp: bool = True): + return getattr(self.client, method)( + f"/api/projects/{self.team.id}/feature_flags/", + body or {}, + HTTP_AUTHORIZATION=f"Bearer {self.key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"} if mcp else None, + ) + + def test_read_only_org_denies_mcp_writes_and_allows_reads(self) -> None: + self._set_read_only(True) + + denied = self._request("post", {"key": "mcp-e2e-should-fail", "name": "e2e"}) + assert denied.status_code == 403 + assert "read-only" in denied.json()["detail"] + + assert self._request("get").status_code == 200 + + @parameterized.expand([("flag_off", False, True), ("not_mcp_user_agent", True, False)]) + def test_writes_pass_without_flag_or_marker(self, _name: str, read_only: bool, mcp: bool) -> None: + self._set_read_only(read_only) + + response = self._request("post", {"key": f"flag-{_name}", "name": "e2e"}, mcp=mcp) + assert response.status_code == 201 + + def test_root_create_is_capped_against_the_current_org(self) -> None: + # A create has no object to defer to; it lands in the caller's current org, which + # here is the read-only one. POST /api/projects/ must be capped. + self._set_read_only(True) + + response = self.client.post( + "/api/projects/", + {"name": "new project via mcp"}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"}, + ) + assert response.status_code == 403 + assert "read-only" in response.json()["detail"] + + def test_root_viewset_caps_against_the_target_org_not_current_org(self) -> None: + # The read-only org is the target; the caller's *current* org is a different, + # uncapped one. A root environment write must be capped against the target. + from posthog.models.organization import Organization + + self._set_read_only(True) + other_org, _, _ = Organization.objects.bootstrap(self.user, name="uncapped current org") + self.user.current_organization = other_org + self.user.save() + + response = self.client.patch( + f"/api/environments/{self.team.id}/", + {"name": "renamed via mcp"}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"}, + ) + assert response.status_code == 403 + assert "read-only" in response.json()["detail"] + + def test_dangerously_defined_permission_chains_are_still_capped(self) -> None: + self._set_read_only(True) + + response = self.client.patch( + "/api/organizations/@current/", + {"name": "renamed via mcp"}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"}, + ) + assert response.status_code == 403 + assert "read-only" in response.json()["detail"] + + def test_non_member_gets_the_generic_denial_not_the_policy_message(self) -> None: + from posthog.models.organization import Organization + + self._set_read_only(True) + _, _, other_team = Organization.objects.bootstrap(None, name="other org") + + response = self.client.post( + f"/api/projects/{other_team.id}/feature_flags/", + {"key": "cross-org-probe", "name": "probe"}, + HTTP_AUTHORIZATION=f"Bearer {self.key_value}", + headers={"User-Agent": f"cursor/1.0 {MCP_USER_AGENT_MARKER}; version: 1.0.0"}, + ) + assert response.status_code == 403 + assert "read-only" not in response.json()["detail"] + + def test_flag_without_entitlement_does_not_enforce(self) -> None: + self._set_read_only(True) + self.organization.available_product_features = [] + self.organization.save() + + assert self._request("post", {"key": "flag-unentitled", "name": "e2e"}).status_code == 201 + + +class TestIsMCPRequest(SimpleTestCase): + @staticmethod + def _request(authenticator: object, user_agent: str) -> HttpRequest: + request = HttpRequest() + request.META["HTTP_USER_AGENT"] = user_agent + request.successful_authenticator = authenticator # type: ignore[attr-defined] + return request + + @parameterized.expand( + [ + ("personal_api_key", PersonalAPIKeyAuthentication), + ("oauth", OAuthAccessTokenAuthentication), + ("id_jag", IDJagAccessTokenAuthentication), + ] + ) + def test_scoped_token_with_mcp_user_agent_is_mcp(self, _name: str, auth_class: type) -> None: + assert is_mcp_request(self._request(auth_class(), f"cursor/1.0 {MCP_USER_AGENT_MARKER}")) is True + + def test_scoped_token_without_mcp_user_agent_is_not_mcp(self) -> None: + assert is_mcp_request(self._request(PersonalAPIKeyAuthentication(), "curl/8")) is False + + def test_session_auth_is_never_mcp(self) -> None: + assert is_mcp_request(self._request(SessionAuthentication(), MCP_USER_AGENT_MARKER)) is False diff --git a/products/access_control/backend/tests/test_user_access_control.py b/products/access_control/backend/tests/test_user_access_control.py index 2f3393b95382..4f8698afa295 100644 --- a/products/access_control/backend/tests/test_user_access_control.py +++ b/products/access_control/backend/tests/test_user_access_control.py @@ -1286,6 +1286,23 @@ def test_filter_queryset_by_access_level_with_none_resource_and_specific_access( assert self.notebook_3.id in notebook_ids # Created by user assert self.notebook_2.id not in notebook_ids # No access + def test_filter_queryset_with_none_resource_and_no_grants_shows_only_created(self): + from products.notebooks.backend.models import Notebook + + self._create_access_control(resource="notebook", access_level="none") + self._clear_uac_caches() + + notebook_ids = list( + self.user_access_control.filter_queryset_by_access_level(Notebook.objects.all()).values_list( + "id", flat=True + ) + ) + + # Fail closed without object grants: only self-created notebooks, never the unfiltered + # queryset. Logic-layer and background callers reach this filter with no permission layer + # above it, so it cannot rely on the view to enforce the resource level. + assert notebook_ids == [self.notebook_3.id] + def test_filter_queryset_by_access_level_with_resource_access(self): """Test queryset filtering when user has resource-level access""" from products.notebooks.backend.models import Notebook diff --git a/products/access_control/backend/tests/test_user_access_control_pbt.py b/products/access_control/backend/tests/test_user_access_control_pbt.py index bcb945ad973c..0db74a53f2d2 100644 --- a/products/access_control/backend/tests/test_user_access_control_pbt.py +++ b/products/access_control/backend/tests/test_user_access_control_pbt.py @@ -466,7 +466,7 @@ def oracle_visible_object_ids( has_resource_access = oracle_resource_access_level(resource, resource_specs, is_org_admin) != NO_ACCESS_LEVEL creators = creator_ids if model_has_creator else set() - if not has_resource_access and allowed: + if not has_resource_access: return (allowed | creators) & all_ids if blocked: return all_ids - (blocked - creators) @@ -741,8 +741,13 @@ def test_hogql_guard_inputs_resolve_the_same_rows_as_the_queryset_filter(self, s allowlisted = uac.allowlisted_resource_ids_by_scope.get(resource) blocked = uac.blocked_resource_ids_by_scope.get(resource, frozenset()) model_has_creator = model_has_created_by(model_cls) + has_resource_access = uac.has_resource_access(resource) def guard_admits(object_id: str) -> bool: + # No resource access and no allowlist: Database.create_for drops the table, so + # nothing is readable. + if not has_resource_access and not allowlisted: + return False if model_has_creator and object_id in creator_ids: return True if allowlisted: @@ -750,7 +755,7 @@ def guard_admits(object_id: str) -> bool: return object_id not in blocked visible = {object_id for object_id in object_specs_by_id if guard_admits(object_id)} - assert visible == oracle_visible_object_ids( + expected = oracle_visible_object_ids( resource, resource_specs, object_specs_by_id, @@ -758,6 +763,12 @@ def guard_admits(object_id: str) -> bool: model_has_creator=model_has_creator, is_org_admin=False, ) + if has_resource_access or allowlisted: + assert visible == expected + else: + # REST still shows the user's own rows here. HogQL has no table to show them from. + assert visible == set() + assert expected <= creator_ids @given( data=object_resource_and_rows(), diff --git a/products/actions/backend/api/test/__snapshots__/test_action.ambr b/products/actions/backend/api/test/__snapshots__/test_action.ambr index ff7054bf4152..a2688d9d3e7d 100644 --- a/products/actions/backend/api/test/__snapshots__/test_action.ambr +++ b/products/actions/backend/api/test/__snapshots__/test_action.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -208,6 +209,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -275,6 +277,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -354,6 +357,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -555,6 +559,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -695,6 +700,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -836,6 +842,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -903,6 +910,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -982,6 +990,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1158,6 +1167,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1237,6 +1247,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1411,6 +1422,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/products/annotations/backend/api/test/__snapshots__/test_annotation.ambr b/products/annotations/backend/api/test/__snapshots__/test_annotation.ambr index 41480417c3b2..9f1af19b20c5 100644 --- a/products/annotations/backend/api/test/__snapshots__/test_annotation.ambr +++ b/products/annotations/backend/api/test/__snapshots__/test_annotation.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -208,6 +209,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -275,6 +277,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -354,6 +357,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -590,6 +594,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -730,6 +735,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -871,6 +877,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -938,6 +945,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1017,6 +1025,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1750,6 +1759,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1829,6 +1839,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1938,6 +1949,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/products/cdp/backend/api/test/__snapshots__/test_plugin.ambr b/products/cdp/backend/api/test/__snapshots__/test_plugin.ambr index 277c69ae1185..28cf23ac9ae9 100644 --- a/products/cdp/backend/api/test/__snapshots__/test_plugin.ambr +++ b/products/cdp/backend/api/test/__snapshots__/test_plugin.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -122,6 +123,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -174,6 +176,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -260,6 +263,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -359,6 +363,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -404,6 +409,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -458,6 +464,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -503,6 +510,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -555,6 +563,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -641,6 +650,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -740,6 +750,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -785,6 +796,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -839,6 +851,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -891,6 +904,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -986,6 +1000,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1039,6 +1054,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1091,6 +1107,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1198,6 +1215,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1243,6 +1261,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/products/cohorts/backend/backfill/allowlist.py b/products/cohorts/backend/backfill/allowlist.py new file mode 100644 index 000000000000..0be61d101bd3 --- /dev/null +++ b/products/cohorts/backend/backfill/allowlist.py @@ -0,0 +1,42 @@ +"""Parses the finalizer's run allowlist. + +Its own module rather than living in ``finalize.py``: that module reaches ``posthog.tasks`` for the +Celery queue, which imports it back, so anything importing it early sits inside that cycle. The +parser has no dependencies at all, so both the finalizer and the operator tooling can read it. +""" + +from uuid import UUID + +import structlog + +logger = structlog.get_logger(__name__) + + +def parse_run_allowlist(raw: str) -> frozenset[UUID] | None: + """The runs the finalizer may stamp. ``None`` means every run, an empty set means none. + + Grammar mirrors ``realtime_teams._team_in_allowlist``: empty / ``all`` / ``*`` match everything, + ``none`` matches nothing, otherwise a whitespace-tolerant comma list. Two deliberate departures, + both because a readiness stamp cannot be undone. Ids compare as parsed ``UUID``s, so an + unhyphenated or uppercase id from a pasted line still matches rather than silently matching + nothing. And a non-keyword value whose every token was malformed matches nothing rather than + everything: a typo in a restriction must not widen it to the whole fleet. + """ + raw = raw.strip() + if raw == "" or raw.lower() == "all" or raw == "*": + return None + if raw.lower() == "none": + return frozenset() + + allowed: set[UUID] = set() + malformed: list[str] = [] + for part in (segment.strip() for segment in raw.split(",")): + if not part: + continue + try: + allowed.add(UUID(part)) + except ValueError: + malformed.append(part) + if malformed: + logger.error("cohort_backfill_finalizer_allowlist_malformed_tokens", tokens=malformed) + return frozenset(allowed) diff --git a/products/cohorts/backend/backfill/finalize.py b/products/cohorts/backend/backfill/finalize.py index 5814bed59ef5..60429eae1603 100644 --- a/products/cohorts/backend/backfill/finalize.py +++ b/products/cohorts/backend/backfill/finalize.py @@ -28,6 +28,7 @@ from posthog.tasks.utils import CeleryQueue +from products.cohorts.backend.backfill.allowlist import parse_run_allowlist from products.cohorts.backend.backfill.readiness import stamp_events_readiness, stamp_person_properties_readiness from products.cohorts.backend.models.backfill import ( CohortBackfillKind, @@ -67,16 +68,20 @@ "posthog_cohort_backfill_finalizer_held_runs", "Observed backfill runs the finalizer left in reconciling, by reason", # labels: "shortfall" (an outcome was missing), "error" (the pass raised), "gated" (a person - # run parked behind the readiness gate) + # run parked behind the readiness gate), "not_allowlisted" (excluded by + # BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST) ["reason"], - # This is a whole-fleet snapshot served from each worker pod's own registry, so `max` holds a - # drained reason high until the task lands again on the pod that wrote the high reading. - # `observe.py` pushes its equivalent gauges under one job name instead; this one wants the same. - multiprocess_mode="max", + # Each pass is a whole-fleet snapshot, so only the newest reading is correct. `max` pinned the + # gauge at the highest value any celery process ever wrote, including dead ones, so a backlog + # that has since drained still reads as full. Widening the allowlist is meant to be watched on + # `not_allowlisted` going to zero, which `max` could never show. `observe.py` reaches the same + # end by pushing its gauges under one job name. + multiprocess_mode="livemostrecent", ) -@dataclass +# Mutable by design: one pass accumulates counters into it as it walks the runs. +@dataclass(frozen=False) class FinalizerPass: runs_scanned: int = 0 completed: int = 0 @@ -84,6 +89,7 @@ class FinalizerPass: held: int = 0 errored: int = 0 gated: int = 0 + not_allowlisted: int = 0 stamped_participations: int = 0 invalidated_teams: int = 0 @@ -124,8 +130,8 @@ def finalize_backfill_runs() -> FinalizerPass: """One finalizer pass. Returns a summary so callers/tests can assert without scraping logs.""" result = FinalizerPass() if not settings.BEHAVIORAL_BACKFILL_FINALIZER_ENABLED: - # Reset rather than leave the gauge frozen at its last value: multiprocess_mode="max" keeps - # a stale reading alive fleet-wide until the process recycles. + # Reset rather than leave the gauge frozen at its last value: a reason left unwritten while + # the finalizer is off keeps reporting whatever the last enabled pass observed. _publish_held_runs(result) return result @@ -157,18 +163,25 @@ def finalize_backfill_runs() -> FinalizerPass: # rather than bounding a sort, and neither kind pays for the other's parked backlog. kinds = _finalizable_kinds() per_kind = max(1, settings.BEHAVIORAL_BACKFILL_FINALIZER_MAX_RUNS_PER_PASS // len(kinds)) - observed = [ - row - for kind in kinds - for row in CohortBackfillRun.objects.unscoped() - .filter( + allowlist = parse_run_allowlist(settings.BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST) + observed: list[tuple[UUID, int]] = [] + for kind in kinds: + discovered = CohortBackfillRun.objects.unscoped().filter( backfill_kind=kind, status=CohortBackfillRunStatus.RECONCILING, reconcile_observed_at__isnull=False, ) - .order_by("reconcile_observed_at") - .values_list("id", "team_id")[:per_kind] - ] + if allowlist is not None: + # Filtered in SQL, not in Python afterwards. The `[:per_kind]` slice below applies in the + # database, so a post-filter would let an excluded backlog consume the whole budget and + # starve the verified runs — the same starvation the per-kind split above guards against. + # + # Count the exclusions rather than leaving them invisible: the filter holds these runs in + # `reconciling` without touching `result.held`, so nothing else would report how much is + # waiting on the allowlist being widened. + result.not_allowlisted += discovered.exclude(id__in=allowlist).count() + discovered = discovered.filter(id__in=allowlist) + observed.extend(discovered.order_by("reconcile_observed_at").values_list("id", "team_id")[:per_kind]) teams_to_invalidate: set[int] = set() for run_id, team_id in observed: @@ -202,6 +215,7 @@ def _publish_held_runs(result: FinalizerPass) -> None: HELD_RUNS_GAUGE.labels(reason="shortfall").set(result.held) HELD_RUNS_GAUGE.labels(reason="error").set(result.errored) HELD_RUNS_GAUGE.labels(reason="gated").set(result.gated) + HELD_RUNS_GAUGE.labels(reason="not_allowlisted").set(result.not_allowlisted) def _finalize_one_run(run_id: UUID, team_id: int, result: FinalizerPass) -> bool: diff --git a/products/cohorts/backend/backfill/inventory.py b/products/cohorts/backend/backfill/inventory.py new file mode 100644 index 000000000000..55773b17d82c --- /dev/null +++ b/products/cohorts/backend/backfill/inventory.py @@ -0,0 +1,350 @@ +"""Classifies active backfill runs so an operator can drain the wedged ones. + +An active run is not just work in progress: it holds one of the partial uniqueness slots +(``cohort_bfr_active_cohort_kind_uq``, ``cohort_bfr_active_team_kind_uq``), so a run that can never +reach a terminal status blocks its cohort or team from ever backfilling again. Nothing today can tell +those apart from a run that is simply slow, and `observe.py`'s gauges count runs per status without +saying which ones are stuck. + +Every run gets exactly one classification answering *what is it waiting on*. Age is a filter callers +compose on top rather than a bucket of its own, so ``finalizable`` stays a byte-for-byte mirror of the +finalizer's discovery predicate: the operator's verified list has to be exactly the set the finalizer +will stamp, and a "stale" bucket would swallow old runs out of every other bucket. +""" + +from collections.abc import Sequence +from datetime import datetime, timedelta +from typing import Any, Literal, get_args +from uuid import UUID + +from django.conf import settings +from django.db.models import Count, Max, Q +from django.utils import timezone as django_timezone + +from posthog.dataclasses import frozen + +from products.cohorts.backend.backfill.allowlist import parse_run_allowlist +from products.cohorts.backend.models.backfill import ( + ACTIVE_COHORT_BACKFILL_RUN_STATUSES, + CohortBackfillChunk, + CohortBackfillChunkStatus, + CohortBackfillKind, + CohortBackfillRun, + CohortBackfillRunStatus, + CohortBackfillScope, +) + +# The chunk tallies a run needs when it has no chunk rows yet: the grouped chunk query below only +# emits a row per run that has chunks, so runs missing from it fall back to these zeros. +_EMPTY_CHUNK_TALLY: dict[str, Any] = { + "chunks_total": 0, + "chunks_confirmed": 0, + "chunks_failed_exhausted": 0, + "chunk_last_progress_at": None, +} + +RunClassification = Literal[ + "orphaned", + "finalizable", + "awaiting-observation", + "seeding-stalled", + "seeding-healthy", + "blocked", + "awaiting-boundary", +] + +RUN_CLASSIFICATIONS: tuple[RunClassification, ...] = get_args(RunClassification) + +# Classifications a `terminalize` sweep may cancel without extra opt-in. `seeding-stalled` and +# `orphaned` are the two that can never reach a terminal status on their own; the rest are either +# legitimately parked (`blocked`, `awaiting-boundary` — targetable, but only alongside an age cutoff) +# or still owned by the seeder. +DEFAULT_TERMINALIZE_CLASSIFICATIONS: tuple[RunClassification, ...] = ("seeding-stalled", "orphaned") + +# Only meaningful together with an age cutoff: these are parked by design, not broken. +AGE_GATED_TERMINALIZE_CLASSIFICATIONS: tuple[RunClassification, ...] = ("blocked", "awaiting-boundary") + +# Runs the seeder is still working: one is scanning chunks, the other is waiting to be observed. +# Canceling either races a live worker instead of freeing a stuck slot, and it discards seeding +# progress the run would otherwise finish, so it takes an explicit opt-in. `finalizable` needs its +# own opt-in for the mirror-image reason: that work is already *done*. +SEEDER_OWNED_CLASSIFICATIONS: tuple[RunClassification, ...] = ("awaiting-observation", "seeding-healthy") + +# Mirrors `SEEDER_MAX_CHUNK_ATTEMPTS`'s envconfig default (rust/cohort-seeder/src/config.rs). Django +# cannot read the seeder's config, so this is a knob on the command rather than a shared setting. +DEFAULT_MAX_CHUNK_ATTEMPTS = 5 + + +@frozen +class RunFacts: + """Everything ``classify_run`` reads. + + Plain values only: no ORM object reaches the classifier, so the predicates are unit-testable + without a database and cannot accidentally issue a query per run. + """ + + status: str + scope: str + cohort_id: int | None + participations_total: int + participations_open: int + live_participation_cohorts: int + reconcile_observed_at: datetime | None + boundary_established_at: datetime | None + chunks_planned_at: datetime | None + chunks_total: int + chunks_unconfirmed: int + chunks_failed_exhausted: int + chunk_last_progress_at: datetime | None + now: datetime + stalled_after: timedelta + + +@frozen +class RunInventoryRow: + run_id: UUID + team_id: int + backfill_kind: str + scope: str + status: str + trigger_kind: str + cohort_id: int | None + classification: RunClassification + finalizer_gated: bool + allowlisted: bool + created_at: datetime + updated_at: datetime + reconcile_observed_at: datetime | None + participations_total: int + participations_open: int + participations_stamped: int + participations_superseded: int + chunks_total: int + chunks_confirmed: int + chunks_failed_exhausted: int + chunk_last_progress_at: datetime | None + evidence: str + blocked_reason: str + error: str + + +def classify_run(facts: RunFacts) -> RunClassification: + """The single bucket a run belongs to. Raises on a terminal status: callers filter to active.""" + if facts.status not in ACTIVE_COHORT_BACKFILL_RUN_STATUSES: + raise ValueError(f"{facts.status} is not an active backfill run status") + + if _orphan_evidence(facts): + return "orphaned" + + if facts.status == CohortBackfillRunStatus.RECONCILING: + return "finalizable" if facts.reconcile_observed_at is not None else "awaiting-observation" + + if facts.status == CohortBackfillRunStatus.SEEDING: + return "seeding-stalled" if _stall_evidence(facts) else "seeding-healthy" + + if facts.status == CohortBackfillRunStatus.BLOCKED: + return "blocked" + + return "awaiting-boundary" + + +def classification_evidence(facts: RunFacts) -> str: + """Why a run reads as orphaned or stalled, for the operator deciding whether to cancel it.""" + return _orphan_evidence(facts) or _stall_evidence(facts) or "" + + +def _orphan_evidence(facts: RunFacts) -> str: + """An orphan can never finalize regardless of status, so this precedes the status buckets.""" + if facts.scope == CohortBackfillScope.COHORT and facts.cohort_id is None: + # The run's FK is SET_NULL, so a hard-deleted cohort leaves a cohort-scoped run pointing at + # nothing to stamp. + return "cohort-scoped run whose cohort was hard-deleted" + if facts.participations_total == 0: + # Participations CASCADE on Cohort, so a hard delete takes them with it. + return "no participation rows left" + if facts.live_participation_cohorts == 0: + return "every participating cohort is deleted" + if facts.participations_open == 0: + # `record_participation_partial` supersedes a participation while leaving the run active, so + # a run can hold its uniqueness slot with no work left to do. + return "every participation already resolved" + return "" + + +def _stall_evidence(facts: RunFacts) -> str: + """Why a seeding run cannot make progress. Empty means it still can.""" + if facts.chunks_failed_exhausted: + # Provably unclaimable, not a heuristic: `claim_next` only claims a pending/failed chunk with + # `attempts < max`, and the run's CAS out of `seeding` requires every chunk confirmed. Such a + # run stays in `seeding` forever. + return f"{facts.chunks_failed_exhausted} chunk(s) failed at the attempt cap with an expired lease" + + cutoff = facts.now - facts.stalled_after + if facts.chunks_planned_at is None: + if facts.boundary_established_at is not None and facts.boundary_established_at < cutoff: + return "boundary established but no chunks planned" + return "" + + if facts.chunks_total == 0: + # A run whose conditions plan no days legitimately stamps `chunks_planned_at` with zero + # chunks and waits for the completion sweep, so this only reads as stalled once that sweep + # has had time to run. + if facts.chunks_planned_at < cutoff: + return "chunks planned but none exist" + return "" + if facts.chunks_unconfirmed and (facts.chunk_last_progress_at is None or facts.chunk_last_progress_at < cutoff): + return f"{facts.chunks_unconfirmed} unconfirmed chunk(s) with no progress since the cutoff" + return "" + + +def collect_run_inventory( + *, + team_id: int | None = None, + kinds: Sequence[str] | None = None, + statuses: Sequence[str] | None = None, + classifications: Sequence[RunClassification] | None = None, + run_ids: Sequence[UUID] | None = None, + stalled_after: timedelta, + older_than: timedelta | None = None, + max_chunk_attempts: int = DEFAULT_MAX_CHUNK_ATTEMPTS, + now: datetime | None = None, +) -> list[RunInventoryRow]: + """Every active run matching the filters, classified, oldest first. + + The scan is cross-team on purpose, like the finalizer's own discovery: the verified list has to + cover every team the finalizer will stamp, and a per-team read would silently produce a subset. + """ + now = now or django_timezone.now() + allowlist = parse_run_allowlist(settings.BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST) + person_readiness_off = not settings.BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED + + queryset = CohortBackfillRun.objects.unscoped().filter(status__in=statuses or ACTIVE_COHORT_BACKFILL_RUN_STATUSES) + if team_id is not None: + queryset = queryset.filter(team_id=team_id) + if kinds: + queryset = queryset.filter(backfill_kind__in=kinds) + if run_ids: + queryset = queryset.filter(id__in=run_ids) + if older_than is not None: + queryset = queryset.filter(created_at__lt=now - older_than) + + # Annotate the per-run tallies rather than walking the relations: the prod active set is + # dominated by blocked runs, and an N+1 here makes the command unusable mid-cleanup. Only the + # participation aggregates are joined here; the chunk aggregates come from a separate grouped + # query below, because joining both `run_cohorts` and `chunks` in one SELECT multiplies into + # one intermediate row per participation-chunk pair, which a large team run turns into a heavy + # sort even though `distinct=True` keeps the counts right. + runs = list( + queryset.annotate( + participations_total=Count("run_cohorts", distinct=True), + participations_stamped=Count("run_cohorts", filter=Q(run_cohorts__stamped_at__isnull=False), distinct=True), + participations_superseded=Count( + "run_cohorts", filter=Q(run_cohorts__superseded_at__isnull=False), distinct=True + ), + live_participation_cohorts=Count( + "run_cohorts", filter=Q(run_cohorts__cohort__deleted=False), distinct=True + ), + ).order_by("created_at") + ) + + # One relation per query, so each aggregate stays linear in the chunk count. Keyed by run id and + # merged back below; runs with no chunks are simply absent and fall back to `_EMPTY_CHUNK_TALLY`. + chunk_tallies = ( + CohortBackfillChunk.objects.unscoped() + .filter(run_id__in=[run.id for run in runs]) + .values("run_id") + .annotate( + chunks_total=Count("id"), + chunks_confirmed=Count("id", filter=Q(status=CohortBackfillChunkStatus.CONFIRMED)), + chunks_failed_exhausted=Count( + "id", + filter=Q(status=CohortBackfillChunkStatus.FAILED, attempts__gte=max_chunk_attempts) + & (Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lt=now)), + ), + chunk_last_progress_at=Max("updated_at"), + ) + ) + chunks_by_run = {tally["run_id"]: tally for tally in chunk_tallies} + + inventory: list[RunInventoryRow] = [] + for run in runs: + chunks = chunks_by_run.get(run.id, _EMPTY_CHUNK_TALLY) + facts = RunFacts( + status=run.status, + scope=run.scope, + cohort_id=run.cohort_id, + participations_total=run.participations_total, + participations_open=run.participations_total - run.participations_superseded, + live_participation_cohorts=run.live_participation_cohorts, + reconcile_observed_at=run.reconcile_observed_at, + boundary_established_at=run.boundary_established_at, + chunks_planned_at=run.chunks_planned_at, + chunks_total=chunks["chunks_total"], + chunks_unconfirmed=chunks["chunks_total"] - chunks["chunks_confirmed"], + chunks_failed_exhausted=chunks["chunks_failed_exhausted"], + chunk_last_progress_at=chunks["chunk_last_progress_at"], + now=now, + stalled_after=stalled_after, + ) + classification = classify_run(facts) + if classifications and classification not in classifications: + continue + inventory.append( + RunInventoryRow( + run_id=run.id, + team_id=run.team_id, + backfill_kind=run.backfill_kind, + scope=run.scope, + status=run.status, + trigger_kind=run.trigger_kind, + cohort_id=run.cohort_id, + classification=classification, + finalizer_gated=person_readiness_off and run.backfill_kind == CohortBackfillKind.PERSON_PROPERTY, + allowlisted=allowlist is None or run.id in allowlist, + created_at=run.created_at, + updated_at=run.updated_at, + reconcile_observed_at=run.reconcile_observed_at, + participations_total=facts.participations_total, + participations_open=facts.participations_open, + participations_stamped=run.participations_stamped, + participations_superseded=run.participations_superseded, + chunks_total=facts.chunks_total, + chunks_confirmed=chunks["chunks_confirmed"], + chunks_failed_exhausted=facts.chunks_failed_exhausted, + chunk_last_progress_at=facts.chunk_last_progress_at, + evidence=classification_evidence(facts), + blocked_reason=run.blocked_reason, + error=run.error, + ) + ) + return inventory + + +def summarize_inventory(rows: Sequence[RunInventoryRow]) -> dict[RunClassification, int]: + """Counts per classification, every bucket present so a drained one reads as 0, not absent.""" + summary: dict[RunClassification, int] = dict.fromkeys(RUN_CLASSIFICATIONS, 0) + for row in rows: + summary[row.classification] += 1 + return summary + + +def stampable_now(rows: Sequence[RunInventoryRow]) -> list[RunInventoryRow]: + """The runs the finalizer would stamp the moment it is enabled, oldest observation first. + + Excludes runs held by the person readiness gate: they are ``finalizable`` by column but invisible + to the finalizer's kind filter, so verifying one now and putting it on the allowlist would stamp + it much later, whenever that gate opens. + """ + stampable = [row for row in rows if row.classification == "finalizable" and not row.finalizer_gated] + # `reconcile_observed_at` is non-null for every `finalizable` row; the fallback keeps mypy honest. + return sorted(stampable, key=lambda row: row.reconcile_observed_at or row.created_at) + + +def allowlist_env_line(rows: Sequence[RunInventoryRow]) -> str: + """The paste-ready allowlist line for the runs above. + + Emits ``none`` rather than an empty value when there is nothing to stamp: an empty value is read + as "every run", which is the opposite of what an operator who found no verified runs means. + """ + run_ids = ",".join(str(row.run_id) for row in rows) + return f"BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST={run_ids or 'none'}" diff --git a/products/cohorts/backend/backfill/runs.py b/products/cohorts/backend/backfill/runs.py index c1f7860bec6e..5dfd94221c30 100644 --- a/products/cohorts/backend/backfill/runs.py +++ b/products/cohorts/backend/backfill/runs.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime, timedelta from enum import StrEnum from typing import Any +from uuid import UUID from django.conf import settings from django.db import IntegrityError, transaction @@ -739,3 +740,103 @@ def supersede_active_runs(team_id: int, cohort_ids: Iterable[int], *, kind: Coho .filter(id__in=[participation_id for participation_id, _, _ in targets], superseded_at__isnull=True) .update(superseded_at=Now(), error=error) ) + + +@frozen +class CancelOutcome: + cancelled_run_ids: tuple[UUID, ...] = () + superseded_participations: int = 0 + # (run_id, "stamped" | "finalizable" | "not_active") + refused: tuple[tuple[UUID, str], ...] = () + + +def cancel_runs(targets: Iterable[tuple[UUID, int]], *, reason: str, allow_finalizable: bool = False) -> CancelOutcome: + """Terminalize ``(run_id, team_id)`` pairs to ``cancelled`` on an operator's behalf. + + This is the manual counterpart to ``supersede_active_runs``: an automatic writer reacting to a + cohort edit wants that one, which records *why* the backfill stopped mattering. ``cancelled`` + means a person decided the run would never finish, so it stays out of signals, tasks, and request + paths. Its purpose is releasing the partial uniqueness slot an active run holds, which is what + lets the cohort or team be backfilled again. + + Callers pass the team alongside each run rather than a bare id, so every query here is team + scoped and the sweep never reads run rows across teams by id. + + Writes nothing beyond the run and its unresolved participations. No chunk write, because a chunk + under a live lease is fenced on ``claim_epoch`` and an unfenced update from here would race the + worker's own; the seeder's claim, plan, and CAS queries all require an active run status, so + canceling is already enough to stop it. No cache invalidation either, because no readiness stamp + is written and there is nothing stale to drop. + """ + cancelled: list[UUID] = [] + refused: list[tuple[UUID, str]] = [] + participations = 0 + for run_id, team_id in targets: + # One transaction per run. A sweep of fifty must not hold every run's lock at once: the + # cohort save path takes the same locks through `supersede_active_runs`, and a long + # multi-run transaction here would block saves for its whole duration. + with transaction.atomic(): + # Same lock target as the finalizer's `_finalize_one_run`, so cancel and finalize + # serialize on the run row instead of racing. Deliberately not `skip_locked`: an + # operator needs a definite outcome per run, and the wait is bounded by one finalizer + # transaction. + run = ( + CohortBackfillRun.objects.for_team(team_id) + .select_for_update(of=("self",)) + .filter(id=run_id, status__in=ACTIVE_COHORT_BACKFILL_RUN_STATUSES) + .first() + ) + if run is None: + refused.append((run_id, "not_active")) + continue + + open_participations = CohortBackfillRunCohort.objects.for_team(team_id).filter( + run_id=run_id, superseded_at__isnull=True + ) + # Re-read under the lock rather than trusting the inventory the operator looked at: the + # seeder may have observed the run since, and canceling one the finalizer would + # legitimately complete throws away a finished backfill. A run whose participations are + # all superseded is not that: the finalizer would only terminalize it, so refusing it + # would strand it holding its uniqueness slot with nothing able to release it. + if ( + not allow_finalizable + and run.status == CohortBackfillRunStatus.RECONCILING + and run.reconcile_observed_at is not None + and open_participations.exists() + ): + refused.append((run_id, "finalizable")) + continue + + if ( + CohortBackfillRunCohort.objects.for_team(team_id) + .filter(run_id=run_id, stamped_at__isnull=False) + .exists() + ): + # A stamp is one way and the flags service already reads it as proof that + # `cohort_membership` is populated. Canceling behind one would leave the cohort + # marked ready with a run row claiming the backfill was abandoned. + refused.append((run_id, "stamped")) + continue + + # Run row before participation rows, matching the ordering documented in + # `supersede_active_runs`; the reverse deadlocks against the finalizer. + CohortBackfillRun.objects.for_team(team_id).filter(id=run_id).update( + status=CohortBackfillRunStatus.CANCELLED, finished_at=Now(), error=reason + ) + participations += open_participations.filter(stamped_at__isnull=True).update( + superseded_at=Now(), error=reason + ) + cancelled.append(run_id) + logger.info( + "cohort_backfill_run_cancelled", + run_id=str(run_id), + team_id=team_id, + previous_status=run.status, + reason=reason, + ) + + return CancelOutcome( + cancelled_run_ids=tuple(cancelled), + superseded_participations=participations, + refused=tuple(refused), + ) diff --git a/products/cohorts/backend/backfill/test/test_finalize.py b/products/cohorts/backend/backfill/test/test_finalize.py index 1864a04e3ad0..35f682719005 100644 --- a/products/cohorts/backend/backfill/test/test_finalize.py +++ b/products/cohorts/backend/backfill/test/test_finalize.py @@ -1,4 +1,5 @@ import importlib +from datetime import timedelta from posthog.test.base import BaseTest from unittest import mock @@ -376,3 +377,87 @@ def test_cohort_scoped_run_with_a_second_participation_keeps_the_stamp(self) -> self.assertEqual( mock_logger.error.call_args[0][0], "cohort_backfill_finalizer_cohort_scoped_run_participation_count" ) + + @parameterized.expand([("unset", ""), ("all", "all"), ("uppercase", "ALL"), ("star", "*")]) + def test_run_allowlist_keywords_lift_the_restriction(self, _name: str, raw: str) -> None: + run, _ = self._make_run(["completed"]) + + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=raw): + result = finalize_backfill_runs() + + # A fail-closed reading of any of these would silently park the whole backlog on a + # deployment that never set the value. + run.refresh_from_db() + self.assertEqual(run.status, CohortBackfillRunStatus.COMPLETED) + self.assertEqual(result.not_allowlisted, 0) + + def test_run_allowlist_excludes_a_run_that_is_not_listed(self) -> None: + listed, listed_cohorts = self._make_run(["completed"], scope=CohortBackfillScope.COHORT) + unlisted, unlisted_cohorts = self._make_run(["completed"], scope=CohortBackfillScope.COHORT) + + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=str(listed.id)): + result = finalize_backfill_runs() + + listed.refresh_from_db() + unlisted.refresh_from_db() + listed_cohorts[0].refresh_from_db() + unlisted_cohorts[0].refresh_from_db() + self.assertEqual(listed.status, CohortBackfillRunStatus.COMPLETED) + self.assertIsNotNone(listed_cohorts[0].last_backfill_events_at) + # A stamp cannot be undone, so an unverified run must come out of the pass untouched. + self.assertEqual(unlisted.status, CohortBackfillRunStatus.RECONCILING) + self.assertIsNone(unlisted_cohorts[0].last_backfill_events_at) + self.assertEqual(result.not_allowlisted, 1) + + @parameterized.expand( + [ + ("none", "none", 0), + ("all_tokens_malformed", "not-a-uuid", 0), + ("one_good_token", None, 1), + ] + ) + def test_run_allowlist_never_widens_on_a_bad_value(self, _name: str, raw: str | None, expected_stamps: int) -> None: + run, cohorts = self._make_run(["completed"]) + # `None` stands for "the run's own id alongside a malformed token". + value = raw if raw is not None else f"{run.id},garbage" + + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=value): + finalize_backfill_runs() + + cohorts[0].refresh_from_db() + # A typo in a restriction must not degrade into "every run": widening is the direction that + # cannot be walked back. + self.assertEqual(1 if cohorts[0].last_backfill_events_at else 0, expected_stamps) + + @parameterized.expand([("unhyphenated", "hex"), ("uppercase", "upper")]) + def test_run_allowlist_matches_a_pasted_id_in_any_form(self, _name: str, form: str) -> None: + run, cohorts = self._make_run(["completed"]) + raw = run.id.hex if form == "hex" else str(run.id).upper() + + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=raw): + finalize_backfill_runs() + + # Comparing raw strings would make an operator's pasted line silently match nothing. + cohorts[0].refresh_from_db() + self.assertIsNotNone(cohorts[0].last_backfill_events_at) + + @override_settings( + BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED=False, + BEHAVIORAL_BACKFILL_FINALIZER_MAX_RUNS_PER_PASS=2, + ) + def test_excluded_runs_do_not_consume_the_per_kind_budget(self) -> None: + excluded = [self._make_run(["completed"], scope=CohortBackfillScope.COHORT)[0] for _ in range(5)] + CohortBackfillRun.objects.for_team(self.team.id).filter(id__in=[run.id for run in excluded]).update( + reconcile_observed_at=timezone.now() - timedelta(days=1) + ) + allowlisted, cohorts = self._make_run(["completed"], scope=CohortBackfillScope.COHORT) + + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=str(allowlisted.id)): + finalize_backfill_runs() + + # The budget slice is applied in SQL. A Python post-filter would let these five older + # exclusions eat the whole pass and the verified run would never be stamped. + allowlisted.refresh_from_db() + cohorts[0].refresh_from_db() + self.assertEqual(allowlisted.status, CohortBackfillRunStatus.COMPLETED) + self.assertIsNotNone(cohorts[0].last_backfill_events_at) diff --git a/products/cohorts/backend/backfill/test/test_inventory.py b/products/cohorts/backend/backfill/test/test_inventory.py new file mode 100644 index 000000000000..08b9a1ba6111 --- /dev/null +++ b/products/cohorts/backend/backfill/test/test_inventory.py @@ -0,0 +1,278 @@ +from datetime import date, timedelta + +from posthog.test.base import BaseTest + +from django.db import connection +from django.test import SimpleTestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.utils import timezone + +from parameterized import parameterized + +from posthog.tasks.calculate_cohort import finalize_cohort_backfill_runs # noqa: F401 breaks an import cycle + +from products.cohorts.backend.backfill.finalize import finalize_backfill_runs +from products.cohorts.backend.backfill.inventory import ( + RUN_CLASSIFICATIONS, + RunFacts, + allowlist_env_line, + classify_run, + collect_run_inventory, + stampable_now, +) +from products.cohorts.backend.backfill.readiness import ensure_filters_shape_hash +from products.cohorts.backend.models.backfill import ( + ACTIVE_COHORT_BACKFILL_RUN_STATUSES, + CohortBackfillChunk, + CohortBackfillChunkStatus, + CohortBackfillKind, + CohortBackfillRun, + CohortBackfillRunCohort, + CohortBackfillRunStatus, + CohortBackfillScope, + CohortBackfillTrigger, +) +from products.cohorts.backend.models.cohort import Cohort, CohortType + +STALLED_AFTER = timedelta(hours=6) + + +def _facts(**overrides: object) -> RunFacts: + defaults: dict[str, object] = { + "status": CohortBackfillRunStatus.SEEDING, + "scope": CohortBackfillScope.TEAM, + "cohort_id": None, + "participations_total": 1, + "participations_open": 1, + "live_participation_cohorts": 1, + "reconcile_observed_at": None, + "boundary_established_at": None, + "chunks_planned_at": None, + "chunks_total": 0, + "chunks_unconfirmed": 0, + "chunks_failed_exhausted": 0, + "chunk_last_progress_at": None, + "now": timezone.now(), + "stalled_after": STALLED_AFTER, + } + return RunFacts(**{**defaults, **overrides}) # type: ignore[arg-type] + + +class TestClassifyRun(SimpleTestCase): + def test_seeding_run_with_chunks_at_the_attempt_cap_is_stalled(self) -> None: + now = timezone.now() + + facts = _facts( + now=now, + chunks_planned_at=now - timedelta(hours=1), + chunks_total=3, + chunks_unconfirmed=1, + chunks_failed_exhausted=1, + chunk_last_progress_at=now - timedelta(minutes=1), + ) + + # Recent chunk progress must not rescue a run whose remaining chunk can never be reclaimed: + # this is the one provably-unclaimable population the cleanup exists to drain. + self.assertEqual(classify_run(facts), "seeding-stalled") + + def test_seeding_run_with_recent_chunk_progress_is_healthy(self) -> None: + now = timezone.now() + + facts = _facts( + now=now, + chunks_planned_at=now - timedelta(days=30), + chunks_total=100, + chunks_unconfirmed=40, + chunk_last_progress_at=now - timedelta(minutes=2), + ) + + # An old run seeding a long history is healthy, not a cancel target. + self.assertEqual(classify_run(facts), "seeding-healthy") + + @parameterized.expand([("just_planned", 1, "seeding-healthy"), ("long_planned", 48, "seeding-stalled")]) + def test_zero_chunk_run_is_healthy_until_the_completion_sweep_has_had_time( + self, _name: str, planned_hours_ago: int, expected: str + ) -> None: + now = timezone.now() + + facts = _facts(now=now, chunks_planned_at=now - timedelta(hours=planned_hours_ago), chunks_total=0) + + # A run whose conditions plan no days legitimately stamps `chunks_planned_at` with zero + # chunks. Without the grace period it reads as stalled the instant it is planned, and the + # default terminalize sweep cancels a backfill the seeder was about to complete. + self.assertEqual(classify_run(facts), expected) + + @parameterized.expand( + [ + ("cohort_hard_deleted", {"scope": CohortBackfillScope.COHORT, "cohort_id": None}), + ("no_participations", {"participations_total": 0}), + ("all_cohorts_deleted", {"live_participation_cohorts": 0}), + ("all_participations_resolved", {"participations_open": 0}), + ] + ) + def test_orphan_precedes_the_status_buckets(self, _name: str, overrides: dict) -> None: + # Each of these can never finalize, so the status it happens to sit in is not the answer. + facts = _facts(status=CohortBackfillRunStatus.RECONCILING, reconcile_observed_at=timezone.now(), **overrides) + + self.assertEqual(classify_run(facts), "orphaned") + + @parameterized.expand([(status,) for status in ACTIVE_COHORT_BACKFILL_RUN_STATUSES]) + def test_every_active_status_has_a_classification(self, status: str) -> None: + # A status added to the vocabulary without a bucket would otherwise fall through to + # `awaiting-boundary` and read as harmless. + self.assertIn(classify_run(_facts(status=status)), RUN_CLASSIFICATIONS) + + +@override_settings( + REALTIME_COHORT_TEAM_ALLOWLIST="all", + BEHAVIORAL_BACKFILL_FINALIZER_ENABLED=True, + BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED=True, + BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST="all", +) +class TestCollectRunInventory(BaseTest): + def _cohort(self, name: str = "realtime", team_id: int | None = None) -> Cohort: + cohort = Cohort.objects.create( + team_id=team_id or self.team.id, + name=name, + cohort_type=CohortType.REALTIME, + filters={ + "properties": { + "type": "AND", + "values": [ + { + "type": "behavioral", + "key": "$pageview", + "event_type": "events", + "value": "performed_event_multiple", + "conditionHash": "hash", + "time_value": 7, + "time_interval": "day", + "operator": "gte", + "operator_value": 2, + } + ], + } + }, + ) + ensure_filters_shape_hash(cohort) + cohort.refresh_from_db() + return cohort + + def _run( + self, + *, + team_id: int | None = None, + status: str = CohortBackfillRunStatus.SEEDING, + kind: str = CohortBackfillKind.BEHAVIORAL, + observed: bool = False, + cohort: Cohort | None = None, + ) -> CohortBackfillRun: + team_id = team_id or self.team.id + # Cohort scope so several runs can coexist: only one team-scoped run per kind may be active. + cohort = cohort or self._cohort(f"cohort-{CohortBackfillRun.objects.unscoped().count()}", team_id=team_id) + run = CohortBackfillRun.objects.for_team(team_id).create( + team_id=team_id, + backfill_kind=kind, + trigger_kind=CohortBackfillTrigger.COHORT_CREATED, + scope=CohortBackfillScope.COHORT, + cohort=cohort, + status=status, + reconcile_observed_at=timezone.now() if observed else None, + timezone="UTC", + ) + CohortBackfillRunCohort.objects.for_team(team_id).create( + run=run, + team_id=team_id, + cohort=cohort, + filters_shape_hash=cohort.filters_shape_hash or "", + behavioral_filters_shape_hash=cohort.behavioral_filters_shape_hash or "", + person_filters_shape_hash=cohort.person_filters_shape_hash or "", + pinned_filters=cohort.filters, + reconcile_completed_at=timezone.now() if observed else None, + ) + return run + + def test_finalizable_rows_are_exactly_what_the_finalizer_stamps(self) -> None: + finalizable = self._run(status=CohortBackfillRunStatus.RECONCILING, observed=True) + unobserved = self._run(status=CohortBackfillRunStatus.RECONCILING, observed=False) + + rows = {row.run_id: row.classification for row in collect_run_inventory(stalled_after=STALLED_AFTER)} + self.assertEqual(rows[finalizable.id], "finalizable") + self.assertEqual(rows[unobserved.id], "awaiting-observation") + + # The bucket is only useful if it predicts the finalizer, so assert against the finalizer + # itself rather than restating its predicate. + finalize_backfill_runs() + finalizable.refresh_from_db() + unobserved.refresh_from_db() + self.assertEqual(finalizable.status, CohortBackfillRunStatus.COMPLETED) + self.assertEqual(unobserved.status, CohortBackfillRunStatus.RECONCILING) + + def test_inventory_covers_every_team(self) -> None: + other_team = self.organization.teams.create(name="other") + mine = self._run() + theirs = self._run(team_id=other_team.id) + + run_ids = {row.run_id for row in collect_run_inventory(stalled_after=STALLED_AFTER)} + + # A per-team read would silently produce a verified list that is a subset of what the + # finalizer, which scans every team, will stamp. + self.assertEqual(run_ids, {mine.id, theirs.id}) + + def test_query_count_does_not_grow_with_the_run_count(self) -> None: + self._run() + with CaptureQueriesContext(connection) as one_run: + collect_run_inventory(stalled_after=STALLED_AFTER) + + for _ in range(9): + self._run() + with CaptureQueriesContext(connection) as ten_runs: + collect_run_inventory(stalled_after=STALLED_AFTER) + + # The prod active set is large, so an N+1 makes the command unusable mid-cleanup. + self.assertEqual(len(ten_runs.captured_queries), len(one_run.captured_queries)) + + def test_stalled_chunk_tally_respects_the_attempt_cap_and_lease(self) -> None: + run = self._run() + CohortBackfillRun.objects.for_team(self.team.id).filter(id=run.id).update( + chunks_planned_at=timezone.now() - timedelta(hours=1) + ) + chunk = CohortBackfillChunk.objects.for_team(self.team.id).create( + run=run, + team_id=self.team.id, + day=date(2026, 1, 1), + status=CohortBackfillChunkStatus.FAILED, + attempts=5, + lease_expires_at=timezone.now() + timedelta(minutes=5), + ) + + # Still leased, so the seeder may yet reclaim it. + [row] = collect_run_inventory(stalled_after=STALLED_AFTER) + self.assertEqual(row.classification, "seeding-healthy") + + CohortBackfillChunk.objects.for_team(self.team.id).filter(id=chunk.id).update( + lease_expires_at=timezone.now() - timedelta(minutes=5) + ) + [row] = collect_run_inventory(stalled_after=STALLED_AFTER) + self.assertEqual(row.classification, "seeding-stalled") + self.assertEqual(row.chunks_failed_exhausted, 1) + + @override_settings(BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED=False) + def test_person_runs_held_by_the_readiness_gate_are_kept_off_the_allowlist_line(self) -> None: + behavioral = self._run(status=CohortBackfillRunStatus.RECONCILING, observed=True) + self._run(status=CohortBackfillRunStatus.RECONCILING, observed=True, kind=CohortBackfillKind.PERSON_PROPERTY) + + rows = collect_run_inventory(stalled_after=STALLED_AFTER) + + # Both are finalizable by column, but the finalizer's kind filter cannot see the person one, + # so putting it on the allowlist would stamp it whenever that gate opens instead of now. + self.assertEqual([row.run_id for row in stampable_now(rows)], [behavioral.id]) + self.assertEqual( + allowlist_env_line(stampable_now(rows)), + f"BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST={behavioral.id}", + ) + + def test_allowlist_line_says_none_when_there_is_nothing_to_stamp(self) -> None: + # An empty value reads as "every run", the opposite of what an operator who verified nothing + # means. + self.assertEqual(allowlist_env_line([]), "BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=none") diff --git a/products/cohorts/backend/backfill/test/test_manage_cohort_backfill_runs_command.py b/products/cohorts/backend/backfill/test/test_manage_cohort_backfill_runs_command.py new file mode 100644 index 000000000000..6dedbac9bc47 --- /dev/null +++ b/products/cohorts/backend/backfill/test/test_manage_cohort_backfill_runs_command.py @@ -0,0 +1,173 @@ +import io +import re +import json + +from posthog.test.base import BaseTest + +from django.core.management import CommandError, call_command +from django.test import override_settings +from django.utils import timezone + +from parameterized import parameterized + +from posthog.tasks.calculate_cohort import finalize_cohort_backfill_runs # noqa: F401 breaks an import cycle + +from products.cohorts.backend.backfill.finalize import finalize_backfill_runs +from products.cohorts.backend.backfill.readiness import ensure_filters_shape_hash +from products.cohorts.backend.models.backfill import ( + CohortBackfillRun, + CohortBackfillRunCohort, + CohortBackfillRunStatus, + CohortBackfillScope, + CohortBackfillTrigger, +) +from products.cohorts.backend.models.cohort import Cohort, CohortType + + +@override_settings( + REALTIME_COHORT_TEAM_ALLOWLIST="all", + BEHAVIORAL_BACKFILL_FINALIZER_ENABLED=True, + BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED=True, + BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST="all", +) +class TestManageCohortBackfillRuns(BaseTest): + def _cohort(self, name: str) -> Cohort: + cohort = Cohort.objects.create( + team=self.team, + name=name, + cohort_type=CohortType.REALTIME, + filters={ + "properties": { + "type": "AND", + "values": [ + { + "type": "behavioral", + "key": "$pageview", + "event_type": "events", + "value": "performed_event_multiple", + "conditionHash": f"hash-{name}", + "time_value": 7, + "time_interval": "day", + "operator": "gte", + "operator_value": 2, + } + ], + } + }, + ) + ensure_filters_shape_hash(cohort) + cohort.refresh_from_db() + return cohort + + def _run(self, name: str, *, status: str, observed: bool = False) -> CohortBackfillRun: + cohort = self._cohort(name) + run = CohortBackfillRun.objects.for_team(self.team.id).create( + team_id=self.team.id, + trigger_kind=CohortBackfillTrigger.TEAM_ENABLEMENT, + scope=CohortBackfillScope.COHORT, + cohort=cohort, + status=status, + reconcile_observed_at=timezone.now() if observed else None, + timezone="UTC", + ) + CohortBackfillRunCohort.objects.for_team(self.team.id).create( + run=run, + team_id=self.team.id, + cohort=cohort, + filters_shape_hash=cohort.filters_shape_hash or "", + behavioral_filters_shape_hash=cohort.behavioral_filters_shape_hash or "", + person_filters_shape_hash=cohort.person_filters_shape_hash or "", + pinned_filters=cohort.filters, + reconcile_completed_at=timezone.now() if observed else None, + ) + return run + + def _call(self, *args: str) -> str: + out = io.StringIO() + call_command("manage_cohort_backfill_runs", *args, stdout=out, stderr=out) + return out.getvalue() + + def test_inventory_allowlist_line_round_trips_through_the_finalizer(self) -> None: + stampable = self._run("stampable", status=CohortBackfillRunStatus.RECONCILING, observed=True) + unobserved = self._run("unobserved", status=CohortBackfillRunStatus.RECONCILING, observed=False) + + output = self._call("inventory") + [line] = re.findall(r"^BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=(.*)$", output, re.MULTILINE) + + # Feed the emitted value back through the parser rather than eyeballing it: a line with a + # stray space, a trailing comma, or an empty value silently reads as "every run", which is + # the worst possible outcome of a change whose stamps cannot be undone. + with override_settings(BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST=line): + finalize_backfill_runs() + + stampable.refresh_from_db() + unobserved.refresh_from_db() + self.assertEqual(stampable.status, CohortBackfillRunStatus.COMPLETED) + self.assertEqual(unobserved.status, CohortBackfillRunStatus.RECONCILING) + + def test_inventory_json_output_is_one_parseable_document(self) -> None: + self._run("seeding", status=CohortBackfillRunStatus.SEEDING) + + payload = json.loads(self._call("inventory", "--format", "json")) + + # Heading text leaking into the stream would break the machine-readable path the runbook + # depends on for recording the verified list. + self.assertEqual(len(payload["runs"]), 1) + self.assertEqual(payload["summary"]["seeding-healthy"], 1) + + def test_terminalize_dry_run_writes_nothing(self) -> None: + run = self._run("orphan", status=CohortBackfillRunStatus.SEEDING) + CohortBackfillRunCohort.objects.for_team(self.team.id).filter(run_id=run.id).update( + superseded_at=timezone.now() + ) + + output = self._call("terminalize", "--classification", "orphaned") + + run.refresh_from_db() + self.assertIn("Dry run", output) + self.assertEqual(run.status, CohortBackfillRunStatus.SEEDING) + + self._call("terminalize", "--classification", "orphaned", "--live-run", "--yes") + + run.refresh_from_db() + self.assertEqual(run.status, CohortBackfillRunStatus.CANCELLED) + + def test_terminalize_needs_a_target(self) -> None: + # There is no cancel-everything-active mode, and a default that swept the whole active set + # would be irreversible for every team at once. + with self.assertRaisesMessage(CommandError, "--classification or --run-id"): + self._call("terminalize", "--live-run", "--yes") + + def test_terminalize_aborts_over_the_max_runs_cap_without_writing(self) -> None: + runs = [self._run(f"orphan-{index}", status=CohortBackfillRunStatus.SEEDING) for index in range(3)] + CohortBackfillRunCohort.objects.for_team(self.team.id).filter(run_id__in=[run.id for run in runs]).update( + superseded_at=timezone.now() + ) + + with self.assertRaisesMessage(CommandError, "over the --max-runs cap"): + self._call("terminalize", "--classification", "orphaned", "--max-runs", "2", "--live-run", "--yes") + + for run in runs: + run.refresh_from_db() + self.assertEqual(run.status, CohortBackfillRunStatus.SEEDING) + + @parameterized.expand( + [ + ("awaiting_observation", CohortBackfillRunStatus.RECONCILING), + ("seeding_healthy", CohortBackfillRunStatus.SEEDING), + ] + ) + def test_terminalize_guards_seeder_owned_runs_named_by_run_id(self, _name: str, status: str) -> None: + run = self._run("live", status=status) + + # Guarding only the --classification values would let a run id name a run the seeder is + # still working and skip every rule, canceling it out from under a live worker. + with self.assertRaisesMessage(CommandError, "still owned by the seeder"): + self._call("terminalize", "--run-id", str(run.id), "--live-run", "--yes") + run.refresh_from_db() + self.assertEqual(run.status, status) + + self._call("terminalize", "--run-id", str(run.id), "--include-seeder-owned", "--live-run", "--yes") + + run.refresh_from_db() + self.assertEqual(run.status, CohortBackfillRunStatus.CANCELLED) diff --git a/products/cohorts/backend/backfill/test/test_runs.py b/products/cohorts/backend/backfill/test/test_runs.py index dd9f3834b715..2af9118d5313 100644 --- a/products/cohorts/backend/backfill/test/test_runs.py +++ b/products/cohorts/backend/backfill/test/test_runs.py @@ -15,6 +15,7 @@ BackfillRunAttempt, attempt_backfill_run_for_cohort, attempt_person_backfill_run_for_cohort, + cancel_runs, create_backfill_run_for_cohort, create_person_backfill_run_for_cohort, create_person_team_backfill_run, @@ -708,3 +709,137 @@ def test_team_run_requires_person_attestations( with self.settings(**{setting_name: False}), self.assertRaisesMessage(ValueError, expected_error): create_person_team_backfill_run(self.team.id, "team_enablement", 30) + + +@override_settings( + REALTIME_COHORT_TEAM_ALLOWLIST="all", + BEHAVIORAL_BACKFILL_MERGE_GATE_ATTESTED=True, + BEHAVIORAL_BACKFILL_DURABILITY_ATTESTED=True, +) +class TestCancelRuns(BaseTest): + def _cohort(self, event: str = "$pageview") -> Cohort: + return Cohort.objects.create( + team=self.team, + name=event, + cohort_type=CohortType.REALTIME, + filters={ + "properties": { + "type": "AND", + "values": [ + { + "type": "behavioral", + "key": event, + "event_type": "events", + "value": "performed_event_multiple", + "conditionHash": f"hash-{event}", + "time_value": 7, + "time_interval": "day", + "operator": "gte", + "operator_value": 2, + } + ], + } + }, + ) + + @parameterized.expand( + [ + ("cohort_scoped", CohortBackfillScope.COHORT), + ("team_scoped", CohortBackfillScope.TEAM), + ] + ) + def test_cancel_frees_the_active_uniqueness_slot(self, _name: str, scope: str) -> None: + cohort = self._cohort() + if scope == CohortBackfillScope.COHORT: + run = create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_created") + else: + run = create_team_backfill_run(self.team.id, "team_enablement") + assert run is not None + CohortBackfillRun.objects.for_team(self.team.id).filter(id=run.id).update( + status=CohortBackfillRunStatus.SEEDING + ) + self.assertIsNone(create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_edited")) + + outcome = cancel_runs([(run.id, self.team.id)], reason="wedged in seeding") + + run.refresh_from_db() + self.assertEqual(outcome.cancelled_run_ids, (run.id,)) + self.assertEqual(run.status, CohortBackfillRunStatus.CANCELLED) + self.assertIsNotNone(run.finished_at) + self.assertEqual(run.error, "wedged in seeding") + # Releasing the partial unique constraint is the whole point: a run nobody can finish + # otherwise blocks its cohort or team from ever backfilling again. + self.assertIsNotNone(create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_edited")) + + def test_cancel_refuses_a_run_whose_readiness_was_already_stamped(self) -> None: + cohort = self._cohort() + run = create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_created") + assert run is not None + CohortBackfillRunCohort.objects.for_team(self.team.id).filter(run_id=run.id).update( + stamped_at=datetime.now(UTC) + ) + + outcome = cancel_runs([(run.id, self.team.id)], reason="sweep") + + run.refresh_from_db() + # A stamp is one way and the flags service already reads it, so a cancel behind one would + # leave the cohort marked ready by a run claiming it was abandoned. + self.assertEqual(outcome.refused, ((run.id, "stamped"),)) + self.assertEqual(run.status, CohortBackfillRunStatus.AWAITING_BOUNDARY) + + @parameterized.expand([("refused", False), ("allowed", True)]) + def test_cancel_only_touches_a_finalizable_run_on_request(self, _name: str, allow: bool) -> None: + cohort = self._cohort() + run = create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_created") + assert run is not None + CohortBackfillRun.objects.for_team(self.team.id).filter(id=run.id).update( + status=CohortBackfillRunStatus.RECONCILING, reconcile_observed_at=datetime.now(UTC) + ) + + outcome = cancel_runs([(run.id, self.team.id)], reason="sweep", allow_finalizable=allow) + + run.refresh_from_db() + # The seeder may have observed the run since the operator listed it, and this one is a + # finished backfill the finalizer would legitimately complete. + if allow: + self.assertEqual(outcome.cancelled_run_ids, (run.id,)) + self.assertEqual(run.status, CohortBackfillRunStatus.CANCELLED) + else: + self.assertEqual(outcome.refused, ((run.id, "finalizable"),)) + self.assertEqual(run.status, CohortBackfillRunStatus.RECONCILING) + + def test_cancel_keeps_an_earlier_supersession_message(self) -> None: + cohort = self._cohort() + run = create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_created") + assert run is not None + supersede_active_runs(self.team.id, [cohort.id], kind=CohortBackfillKind.BEHAVIORAL) + CohortBackfillRun.objects.for_team(self.team.id).filter(id=run.id).update( + status=CohortBackfillRunStatus.SEEDING + ) + participation = CohortBackfillRunCohort.objects.for_team(self.team.id).get(run_id=run.id) + + cancel_runs([(run.id, self.team.id)], reason="operator sweep") + + participation.refresh_from_db() + # The edit-time supersession is why this backfill stopped mattering; operator text must not + # overwrite that provenance. + self.assertEqual(participation.error, "Cohort definition changed during backfill") + + def test_cancel_drains_an_observed_run_whose_participations_are_all_resolved(self) -> None: + cohort = self._cohort() + run = create_backfill_run_for_cohort(self.team.id, cohort.id, "cohort_created") + assert run is not None + CohortBackfillRunCohort.objects.for_team(self.team.id).filter(run_id=run.id).update( + superseded_at=datetime.now(UTC) + ) + CohortBackfillRun.objects.for_team(self.team.id).filter(id=run.id).update( + status=CohortBackfillRunStatus.RECONCILING, reconcile_observed_at=datetime.now(UTC) + ) + + outcome = cancel_runs([(run.id, self.team.id)], reason="orphaned sweep") + + run.refresh_from_db() + # The inventory classifies this `orphaned`, not `finalizable`, because the finalizer would + # only terminalize it. Refusing it here would leave nothing able to release its slot. + self.assertEqual(outcome.cancelled_run_ids, (run.id,)) + self.assertEqual(run.status, CohortBackfillRunStatus.CANCELLED) diff --git a/products/cohorts/backend/management/commands/manage_cohort_backfill_runs.py b/products/cohorts/backend/management/commands/manage_cohort_backfill_runs.py new file mode 100644 index 000000000000..b05d23aff557 --- /dev/null +++ b/products/cohorts/backend/management/commands/manage_cohort_backfill_runs.py @@ -0,0 +1,334 @@ +"""Ops tooling for the realtime cohort backfill run set, meant for a toolbox pod. + +`inventory` lists every backfill run in an active status across all teams, says what each one is +waiting on, and emits the paste-ready `BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST` line for the runs +the finalizer would stamp. `terminalize` cancels the ones that can never finish, releasing the +uniqueness slot that otherwise blocks their cohort or team from backfilling again. Mutating actions +are dry run by default. + +Order matters when turning the finalizer on, because a readiness stamp cannot be undone: + + 1. Deploy with `BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST` set to a verified list or `none`. + 2. Run `inventory` while the finalizer is still off, so the finalizable set holds still. + 3. Check each finalizable run by hand. + 4. `terminalize --classification seeding-stalled --classification orphaned --live-run`. + 5. Re-run `inventory` and set the allowlist line it prints. + 6. Turn on `BEHAVIORAL_BACKFILL_FINALIZER_ENABLED`, then watch + `posthog_cohort_backfill_finalizer_held_runs{reason="not_allowlisted"}` drain as the list widens. +""" + +import sys +import json +from dataclasses import asdict +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError, CommandParser +from django.core.serializers.json import DjangoJSONEncoder + +import structlog + +from products.cohorts.backend.backfill.allowlist import parse_run_allowlist +from products.cohorts.backend.backfill.inventory import ( + AGE_GATED_TERMINALIZE_CLASSIFICATIONS, + DEFAULT_MAX_CHUNK_ATTEMPTS, + DEFAULT_TERMINALIZE_CLASSIFICATIONS, + RUN_CLASSIFICATIONS, + SEEDER_OWNED_CLASSIFICATIONS, + RunInventoryRow, + allowlist_env_line, + collect_run_inventory, + stampable_now, + summarize_inventory, +) +from products.cohorts.backend.backfill.runs import cancel_runs +from products.cohorts.backend.models.backfill import ACTIVE_COHORT_BACKFILL_RUN_STATUSES, CohortBackfillKind + +logger = structlog.get_logger(__name__) + +DEFAULT_REASON = "canceled via manage_cohort_backfill_runs" +DEFAULT_STALLED_FOR_HOURS = 6 +MAX_RUNS_DEFAULT = 50 +PRINT_LIMIT = 50 +DRY_RUN_MESSAGE = "Dry run, nothing written. Re-run with --live-run to apply." + + +class Command(BaseCommand): + help = "Inventory the active cohort backfill runs, and cancel the ones that can never finish." + + def add_arguments(self, parser: CommandParser) -> None: + subparsers = parser.add_subparsers(dest="action", required=True) + + inventory = subparsers.add_parser("inventory", help="List and classify the active runs.") + self._add_target_args(inventory) + inventory.add_argument("--format", choices=["table", "json"], default="table") + inventory.add_argument("--limit", type=int, default=None, help="Cap the runs listed per section.") + + terminalize = subparsers.add_parser("terminalize", help="Cancel the targeted runs.") + self._add_target_args(terminalize) + terminalize.add_argument("--reason", default=DEFAULT_REASON, help="Recorded on each canceled run.") + terminalize.add_argument( + "--include-finalizable", + action="store_true", + help="Also cancel runs the finalizer would stamp. Throws away a finished backfill.", + ) + terminalize.add_argument( + "--include-seeder-owned", + action="store_true", + help="Also cancel runs the seeder is still working. Throws away seeding progress.", + ) + terminalize.add_argument("--live-run", action="store_true") + terminalize.add_argument("--yes", action="store_true", help="Skip the confirmation prompt.") + terminalize.add_argument("--max-runs", type=int, default=MAX_RUNS_DEFAULT) + + def _add_target_args(self, parser: CommandParser) -> None: + parser.add_argument("--team-id", type=int, default=None) + parser.add_argument("--kind", action="append", choices=list(CohortBackfillKind.values), default=None) + parser.add_argument( + "--status", + action="append", + choices=[status.value for status in ACTIVE_COHORT_BACKFILL_RUN_STATUSES], + default=None, + help="Only active statuses: this command drains the active set.", + ) + parser.add_argument("--classification", action="append", choices=list(RUN_CLASSIFICATIONS), default=None) + parser.add_argument("--run-id", action="append", default=None) + parser.add_argument("--older-than-hours", type=float, default=None, help="Only runs created before this.") + parser.add_argument( + "--stalled-for-hours", + type=float, + default=DEFAULT_STALLED_FOR_HOURS, + help="How long a seeding run may go without chunk progress before it reads as stalled.", + ) + parser.add_argument( + "--max-chunk-attempts", + type=int, + default=DEFAULT_MAX_CHUNK_ATTEMPTS, + # Django cannot read the seeder's config, so the cap it retries chunks up to has to be + # repeated here. Pass the deployed value if it differs from the seeder's default. + help="The seeder's SEEDER_MAX_CHUNK_ATTEMPTS, used to spot chunks that can't be reclaimed.", + ) + + def handle(self, *args: Any, **options: Any) -> None: + if options["action"] == "inventory": + self._inventory(options) + return + self._terminalize(options) + + # -- inventory -------------------------------------------------------------- + + def _inventory(self, options: dict[str, Any]) -> None: + rows = self._collect(options) + stampable = stampable_now(rows) + if options["format"] == "json": + # One document, no headings, so it pipes into jq. + self.stdout.write( + json.dumps( + { + "settings": self._settings_snapshot(), + "max_chunk_attempts": options["max_chunk_attempts"], + "summary": summarize_inventory(rows), + "runs": [asdict(row) for row in rows], + "allowlist_line": allowlist_env_line(stampable), + }, + cls=DjangoJSONEncoder, + ) + ) + return + + limit = PRINT_LIMIT if options["limit"] is None else options["limit"] + self.stdout.write(self.style.MIGRATE_HEADING("Finalizer settings")) + for name, value in self._settings_snapshot().items(): + self.stdout.write(f" {name}={value}") + self.stdout.write(f" {self._classification_cap_line(options)}") + + summary = summarize_inventory(rows) + teams = len({row.team_id for row in rows}) + self.stdout.write( + self.style.MIGRATE_HEADING(f"\nActive runs by classification ({len(rows)} across {teams} team(s))") + ) + for classification, count in summary.items(): + self.stdout.write(f" {classification:<22}{count}") + + self.stdout.write( + self.style.MIGRATE_HEADING( + f"\nFinalizable now ({len(stampable)}). These get stamped as soon as the finalizer is enabled." + ) + ) + # Never truncated, unlike every other section. These are the runs the allowlist line below + # carries, and a stamp cannot be undone, so the operator has to see each one to check it. + self._print_rows(stampable, len(stampable)) + + gated = [row for row in rows if row.classification == "finalizable" and row.finalizer_gated] + if gated: + self.stdout.write( + self.style.MIGRATE_HEADING(f"\nFinalizable but held by the person readiness gate ({len(gated)})") + ) + self._print_rows(gated, limit) + + candidates = [row for row in rows if row.classification in DEFAULT_TERMINALIZE_CLASSIFICATIONS] + self.stdout.write(self.style.MIGRATE_HEADING(f"\nCancel candidates ({len(candidates)})")) + self._print_rows(candidates, limit) + + self.stdout.write(self.style.MIGRATE_HEADING("\nAllowlist line. Paste it after checking each run above.")) + self.stdout.write(allowlist_env_line(stampable)) + + def _print_rows(self, rows: list[RunInventoryRow], limit: int) -> None: + for row in rows[:limit]: + line = ( + f" run={row.run_id} team={row.team_id} kind={row.backfill_kind} scope={row.scope} " + f"cohort={row.cohort_id if row.cohort_id is not None else '-'} status={row.status} " + f"age={_age(row.created_at)} parts={row.participations_open}/{row.participations_total} " + f"chunks={row.chunks_confirmed}/{row.chunks_total}" + ) + if row.evidence: + line += f" why={row.evidence}" + self.stdout.write(line) + if len(rows) > limit: + self.stdout.write(f" ... and {len(rows) - limit} more") + + # -- terminalize ------------------------------------------------------------ + + def _terminalize(self, options: dict[str, Any]) -> None: + classifications = options["classification"] + run_ids = options["run_id"] + if not classifications and not run_ids: + raise CommandError( + "Pass --classification or --run-id. There is no cancel-everything mode. A good " + f"starting point is {' '.join(f'--classification {name}' for name in DEFAULT_TERMINALIZE_CLASSIFICATIONS)}" + ) + + rows = self._collect(options) + # Guard on what each targeted run actually is, not on what was asked for. Checking the + # `--classification` values alone would let `--run-id` name a run in a protected + # classification and skip every rule below. + for classification in sorted({row.classification for row in rows}): + if classification in SEEDER_OWNED_CLASSIFICATIONS and not options["include_seeder_owned"]: + raise CommandError( + f"{classification} runs are still owned by the seeder, so canceling one races a live " + "worker and discards seeding progress. Target seeding-stalled instead, or pass " + "--include-seeder-owned to stop live work deliberately." + ) + if classification in AGE_GATED_TERMINALIZE_CLASSIFICATIONS and options["older_than_hours"] is None: + raise CommandError(f"{classification} runs are parked by design, so pass --older-than-hours too") + if classification == "finalizable" and not options["include_finalizable"]: + raise CommandError( + "finalizable runs are finished backfills the finalizer would stamp. " + "Pass --include-finalizable to throw that work away deliberately." + ) + + if not rows: + self.stdout.write("No runs matched. Run `inventory` to see what is active.") + return + + # Abort before the prompt, not after: an operator who mistargeted should not be asked to + # confirm a sweep this command was never going to let through. + if len(rows) > options["max_runs"]: + raise CommandError( + f"{len(rows)} runs matched, over the --max-runs cap of {options['max_runs']}. " + "Narrow the targeting or raise the cap deliberately." + ) + + self.stdout.write(self.style.MIGRATE_HEADING(f"Runs to cancel ({len(rows)})")) + self._print_rows(rows, PRINT_LIMIT) + + # The cap only shapes the `seeding-stalled` classification, so it is only worth flagging + # before the confirmation when one of those runs is in the cancel set. + if any(row.classification == "seeding-stalled" for row in rows): + self.stdout.write(f" {self._classification_cap_line(options)}") + + if not options["live_run"]: + self.stdout.write(self.style.WARNING(DRY_RUN_MESSAGE)) + return + + self._confirm(f"Cancel {len(rows)} run(s)? Type 'cancel' to continue: ", "cancel", yes=options["yes"]) + + classification_by_run = {row.run_id: row.classification for row in rows} + outcome = cancel_runs( + [(row.run_id, row.team_id) for row in rows], + reason=options["reason"], + allow_finalizable=options["include_finalizable"], + ) + for run_id in outcome.cancelled_run_ids: + logger.info( + "manage_cohort_backfill_runs_cancelled", + run_id=str(run_id), + classification=classification_by_run[run_id], + reason=options["reason"], + ) + for run_id, refusal in outcome.refused: + self.stdout.write(self.style.WARNING(f" run={run_id} not canceled: {refusal}")) + + self.stdout.write( + self.style.SUCCESS( + f"Canceled {len(outcome.cancelled_run_ids)} run(s), " + f"resolved {outcome.superseded_participations} participation(s), " + f"refused {len(outcome.refused)}." + ) + ) + + # -- shared ----------------------------------------------------------------- + + def _collect(self, options: dict[str, Any]) -> list[RunInventoryRow]: + older_than_hours = options["older_than_hours"] + return collect_run_inventory( + team_id=options["team_id"], + kinds=options["kind"], + statuses=options["status"], + classifications=options["classification"], + run_ids=_parse_run_ids(options["run_id"]), + stalled_after=timedelta(hours=options["stalled_for_hours"]), + older_than=None if older_than_hours is None else timedelta(hours=older_than_hours), + max_chunk_attempts=options["max_chunk_attempts"], + ) + + def _classification_cap_line(self, options: dict[str, Any]) -> str: + # The cap Django classified against is invisible in the run listing otherwise, so a + # `seeding-stalled` reading looks identical whether it rests on the default or a value the + # operator passed. Surfacing it lets them catch a deployed SEEDER_MAX_CHUNK_ATTEMPTS the + # inventory undershot, which would misread a still-retryable chunk as provably wedged. + return ( + f"max_chunk_attempts={options['max_chunk_attempts']} " + "(assumed seeder cap; pass --max-chunk-attempts if the deployed SEEDER_MAX_CHUNK_ATTEMPTS is higher)" + ) + + def _settings_snapshot(self) -> dict[str, Any]: + allowlist = parse_run_allowlist(settings.BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST) + parsed = "every run" if allowlist is None else f"{len(allowlist)} run(s)" + return { + "BEHAVIORAL_BACKFILL_FINALIZER_ENABLED": settings.BEHAVIORAL_BACKFILL_FINALIZER_ENABLED, + "BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED": settings.BEHAVIORAL_BACKFILL_PERSON_READINESS_ENABLED, + "BEHAVIORAL_BACKFILL_FINALIZER_MAX_RUNS_PER_PASS": settings.BEHAVIORAL_BACKFILL_FINALIZER_MAX_RUNS_PER_PASS, + "BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST": ( + f"{settings.BEHAVIORAL_BACKFILL_FINALIZER_RUN_ALLOWLIST} (matches {parsed})" + ), + } + + # Mirrors `manage_warehouse_queue._confirm`. + def _confirm(self, prompt: str, keyword: str, *, yes: bool) -> None: + if yes: + return + if not sys.stdin.isatty(): + raise CommandError("Refusing to apply changes non-interactively without --yes") + if input(prompt).strip() != keyword: + raise CommandError("Aborted.") + + +def _parse_run_ids(raw_ids: list[str] | None) -> list[UUID] | None: + if not raw_ids: + return None + try: + return [UUID(raw) for raw in raw_ids] + except ValueError as error: + raise CommandError(f"--run-id must be a UUID: {error}") + + +def _age(moment: datetime) -> str: + seconds = abs((datetime.now(UTC) - moment).total_seconds()) + if seconds < 120: + return f"{int(seconds)}s" + if seconds < 2 * 3600: + return f"{int(seconds // 60)}m" + return f"{seconds / 3600:.1f}h" diff --git a/products/desktop/packages/ui/package.json b/products/desktop/packages/ui/package.json index cd28f9f4e159..7e4b5217f2f3 100644 --- a/products/desktop/packages/ui/package.json +++ b/products/desktop/packages/ui/package.json @@ -90,7 +90,7 @@ "fzf": "^0.5.2", "inversify": "catalog:", "lucide-react": "^1.7.0", - "posthog-js": "^1.418.17", + "posthog-js": "^1.420.0", "radix-themes-tw": "0.2.3", "react-hotkeys-hook": "^4.4.4", "react-markdown": "^10.1.0", diff --git a/products/desktop/pnpm-lock.yaml b/products/desktop/pnpm-lock.yaml index 54e36d40f6a1..001e681b8132 100644 --- a/products/desktop/pnpm-lock.yaml +++ b/products/desktop/pnpm-lock.yaml @@ -1485,8 +1485,8 @@ importers: specifier: ^1.7.0 version: 1.7.0(react@19.2.6) posthog-js: - specifier: ^1.418.17 - version: 1.418.17 + specifier: ^1.420.0 + version: 1.420.0(@types/react@19.2.17)(react@19.2.6) radix-themes-tw: specifier: 0.2.3 version: 0.2.3 @@ -1760,8 +1760,8 @@ importers: specifier: workspace:* version: link:../../packages/shared posthog-js: - specifier: ^1.418.17 - version: 1.418.17 + specifier: ^1.420.0 + version: 1.420.0(@types/react@19.2.17)(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -6282,8 +6282,8 @@ packages: react: optional: true - '@posthog/browser-common@0.5.2': - resolution: {integrity: sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==} + '@posthog/browser-common@0.6.0': + resolution: {integrity: sha512-d6yBE7VeoU3JTpaab3CaCoDCseh0Ytx7sTe0v2ZxhtHNxoKk7rdqr92+bUz9F1T2CuJO5/OTkes4HWT2VHNrTA==} '@posthog/cli@0.14.1': resolution: {integrity: sha512-gTzcKpl9TZLf0LrlLHEjChlPc9LIK1gdQG0alMnX6+b+W1mTD+6nTN0W/MeEzjT4DiKDeK8FPhc1n7dT1tWKFw==} @@ -13863,8 +13863,16 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.418.17: - resolution: {integrity: sha512-CP5CxuMcEcJtX4RkBfcwMvrt3jsvyu1UWm2nKCqnlhQp/ejrlz6JADT9vCwq+IZjhC9n4WPiPvmyj//ZrBYZHA==} + posthog-js@1.420.0: + resolution: {integrity: sha512-tIfyJhOCD177n6s5k+0thh7US2x7ZYD0rLJlOIYikDXtjpMIqeSILuZckVedlX9kuZSdpUQvRqoQZpGx4iCqbw==} + peerDependencies: + '@types/react': ^19.2.15 + react: 19.2.6 + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true posthog-node@5.51.1: resolution: {integrity: sha512-uEGB5OQvYl9f8Fy2t4FolhcBBlYB8sZM6/4Us517M5dcg/d00yyCusm0HydZFwbPQtPntlpr3gNAmv8TTbWuaA==} @@ -21010,10 +21018,10 @@ snapshots: optionalDependencies: react: 19.2.6 - '@posthog/browser-common@0.5.2': + '@posthog/browser-common@0.6.0': dependencies: '@posthog/core': 1.48.11 - '@posthog/types': 1.406.1 + '@posthog/types': 1.406.2 '@posthog/cli@0.14.1': dependencies: @@ -29904,11 +29912,11 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-js@1.418.17: + posthog-js@1.420.0(@types/react@19.2.17)(react@19.2.6): dependencies: - '@posthog/browser-common': 0.5.2 + '@posthog/browser-common': 0.6.0 '@posthog/core': 1.48.11 - '@posthog/types': 1.405.3 + '@posthog/types': 1.406.2 core-js: 3.50.0 dompurify: 3.4.13 fflate: 0.4.8 @@ -29916,6 +29924,9 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 web-vitals-soft-navs: web-vitals@6.0.0 + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.6 transitivePeerDependencies: - preact-render-to-string diff --git a/products/desktop/tools/announcements-admin/package.json b/products/desktop/tools/announcements-admin/package.json index a412eefc21bc..407986f05795 100644 --- a/products/desktop/tools/announcements-admin/package.json +++ b/products/desktop/tools/announcements-admin/package.json @@ -13,7 +13,7 @@ "@pierre/diffs": "^1.2.10", "@posthog/brand": "0.9.0", "@posthog/shared": "workspace:*", - "posthog-js": "^1.418.17", + "posthog-js": "^1.420.0", "react": "19.2.6", "react-dom": "19.2.6", "zod": "^4.4.3" diff --git a/products/engineering_analytics/backend/logic/sources.py b/products/engineering_analytics/backend/logic/sources.py index 454eab667205..e4d95d3932d4 100644 --- a/products/engineering_analytics/backend/logic/sources.py +++ b/products/engineering_analytics/backend/logic/sources.py @@ -25,11 +25,10 @@ from typing import TYPE_CHECKING, NamedTuple from uuid import UUID -from django.db.models import Q, QuerySet +from django.db.models import QuerySet from posthog.models.team import Team -from products.access_control.backend.facade.user_access_control import NO_ACCESS_LEVEL from products.engineering_analytics.backend.facade.contracts import GitHubSource, GitHubSourceNotConnectedError from products.warehouse_sources.backend.facade.models import ExternalDataSchema, ExternalDataSource from products.warehouse_sources.backend.facade.sources import github_schema_repo_endpoint @@ -321,16 +320,6 @@ def _accessible_sources( ) if user_access_control is not None: sources = user_access_control.filter_queryset_by_access_level(sources) - if not user_access_control.has_resource_access("external_data_source"): - # "none" resource-level access: the platform filter drops nothing when the user holds no - # object grants, so fail closed here to self-created or explicitly granted sources. - granted_ids = [ - source.id - for source in sources - if (level := user_access_control.access_level_for_object(source, explicit=True)) - and level != NO_ACCESS_LEVEL - ] - sources = sources.filter(Q(created_by=user_access_control.user) | Q(id__in=granted_ids)) return sources diff --git a/products/engineering_analytics/backend/tests/test_views.py b/products/engineering_analytics/backend/tests/test_views.py index 918e5ee13ce9..6dce0a336216 100644 --- a/products/engineering_analytics/backend/tests/test_views.py +++ b/products/engineering_analytics/backend/tests/test_views.py @@ -42,9 +42,9 @@ def setUp(self) -> None: self.organization.save() def test_none_resource_access_fails_closed_to_self_created_sources(self) -> None: - # filter_queryset_by_access_level returns the queryset UNFILTERED for a user with "none" - # resource access and no object grants — without the guard, such a user enumerates every - # GitHub source on the team. + # A user with "none" resource access and no object grants must not enumerate the + # team's sources. The product surface relies on filter_queryset_by_access_level to + # fail closed here. mine = create_github_source(self.team, prefix="mine_", source_id="gh-mine") mine.created_by = self.user mine.save() @@ -59,9 +59,14 @@ def test_none_resource_access_fails_closed_to_self_created_sources(self) -> None ) assert [source.id for source in visible] == [str(mine.id)] - # An explicit object grant survives the fail-closed guard. + # An explicit object grant survives the fail-closed guard. The filter counts only member + # and role rows as grants. A default ("everyone") object row does not count. AccessControl.objects.create( - team=self.team, resource="external_data_source", resource_id=str(theirs.id), access_level="editor" + team=self.team, + resource="external_data_source", + resource_id=str(theirs.id), + access_level="editor", + organization_member=self.organization_membership, ) visible = list_github_sources( team=self.team, user_access_control=UserAccessControl(user=self.user, team=self.team) diff --git a/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr b/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr index 008e2c709915..b501c0bc0fed 100644 --- a/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr +++ b/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr @@ -516,6 +516,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -973,6 +974,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1490,6 +1492,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1569,6 +1572,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/products/feature_flags/backend/api/test/__snapshots__/test_organization_feature_flag.ambr b/products/feature_flags/backend/api/test/__snapshots__/test_organization_feature_flag.ambr index 729387d32c4a..12fcc23eb4a4 100644 --- a/products/feature_flags/backend/api/test/__snapshots__/test_organization_feature_flag.ambr +++ b/products/feature_flags/backend/api/test/__snapshots__/test_organization_feature_flag.ambr @@ -68,6 +68,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -396,6 +397,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -573,6 +575,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -662,6 +665,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -880,6 +884,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -973,6 +978,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1060,6 +1066,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1306,6 +1313,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1431,6 +1439,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1483,6 +1492,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1558,6 +1568,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1758,6 +1769,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1819,6 +1831,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", @@ -1991,6 +2004,7 @@ "posthog_organization"."members_can_use_personal_api_keys", "posthog_organization"."members_can_see_org_members", "posthog_organization"."allow_publicly_shared_resources", + "posthog_organization"."read_only_mcp_access", "posthog_organization"."default_role_id", "posthog_organization"."plugins_access_level", "posthog_organization"."for_internal_metrics", diff --git a/products/mcp_analytics/backend/intent_clustering.py b/products/mcp_analytics/backend/intent_clustering.py index 1caba22daee2..db8fd971ee96 100644 --- a/products/mcp_analytics/backend/intent_clustering.py +++ b/products/mcp_analytics/backend/intent_clustering.py @@ -30,7 +30,7 @@ import asyncio import hashlib from collections import Counter, defaultdict -from collections.abc import Collection +from collections.abc import Collection, Sequence from dataclasses import dataclass, field from typing import Any @@ -44,6 +44,7 @@ from posthog.api.embedding_worker import EmbeddingResponse, async_generate_embedding from posthog.clickhouse.query_tagging import Feature, Product, tags_context +from posthog.dataclasses import frozen from posthog.models.team.team import Team from posthog.sync import database_sync_to_async @@ -148,10 +149,31 @@ class WindowStats: # Intent corpus ----------------------------------------------------------- -# Bound on sessions sampled from ClickHouse for the corpus. Keeps the IN-tuple -# in the per-session queries below at a sane size; a larger sample mostly adds -# long-tail singleton intents past DEFAULT_TOP_N_INTENTS anyway. -MAX_CORPUS_SESSIONS = 2000 +# Total corpus budget. Raised alongside stratified sampling: the per-tool floors +# below have to fit inside it before any of the budget goes to the dominant +# tools, and the per-tool call cap (below) stops the extra sessions from +# belonging only to those tools. The IN-tuple stays sane because per-session +# queries chunk the ids. +MAX_CORPUS_SESSIONS = 6000 + +# Each tool keeps at least this many sessions in the corpus, so a low/mid-volume +# tool (logs/tracing/metrics) survives sampling instead of being erased by the +# dominant exec/scout traffic. ~400 is the statistical floor for reading a +# discovery/capture rate to ±5%. Doubles as the per-tool candidate pool size in +# ``fetch_tools_by_session``: a pool bigger than the floor buys nothing, since +# the rest of the corpus budget comes from the uniform sample. +MIN_SESSIONS_PER_TOOL = 400 + +# No tool may contribute more than this many attributed calls to the corpus. +# Stops one dominant tool from occupying the entire intent space; the freed +# budget is what lets mid/low tools cluster into real themes rather than noise. +MAX_CALLS_PER_TOOL = 1500 + +# Sender-controlled tool names only ever expand the ``_SESSION_TOOLS_SQL`` +# buckets. One session honestly uses a handful of distinct tools, so bound how +# many distinct tools a single session can contribute — an attacker emitting +# thousands of unique names can't fan the buckets out from one session. +MAX_DISTINCT_TOOLS_PER_SESSION_BUCKET = 500 # execute_hogql_query injects LIMIT 100 into any query without an explicit # LIMIT — far below what the per-session queries return at production scale @@ -415,6 +437,218 @@ def fetch_window_stats(team: Team, lookback_days: int = DEFAULT_LOOKBACK_DAYS) - ) +# Intent-bearing sessions bucketed by effective tool, capped *per tool*. +# +# A single cap on candidate sessions cannot work here: the hash-ordered cut runs +# before any bucketing, so at high total volume a low/mid-volume tool's handful +# of sessions falls outside it and the per-tool floor has nothing left to keep — +# the erasure this pipeline exists to prevent. ``LIMIT n BY tool`` gives each +# tool its own pool, so whether a tool reaches the corpus stops depending on the +# team's total session count. +# +# Both dimensions are sender-controlled, so each is bounded: ``groupUniqArray`` +# de-duplicates a session's tool names in aggregate state and ``arraySlice`` +# caps how many of them leave it, while the per-tool cap and the absolute row +# cap bound the rows. Grouping by session alone (rather than session x tool) +# keeps the aggregation one dimension wide, and lets a session qualify on any of +# its calls carrying an intent — the population ``sample_corpus_sessions`` draws +# from. +# +# ``cityHash64`` here and in ``sample_corpus_sessions`` is a fast pseudo-random +# ordering, not a security boundary — the memory/CPU protection comes from the +# numeric caps, not the hash. ``arraySort`` keeps the per-session tool slice +# stable across reruns so repeat runs re-hit the embedding cache. +_SESSION_TOOLS_SQL = """ +SELECT session_id, arrayJoin(tools) AS tool +FROM ( + SELECT + $session_id AS session_id, + arraySlice(arraySort(groupUniqArray(left({tool_expr}, {max_tool_len}))), 1, {max_distinct_tools}) AS tools, + countIf(coalesce(toString(properties.$mcp_intent), '') != '') AS intent_calls + FROM events + WHERE event = {event} + AND timestamp >= now() - INTERVAL {lookback_days} DAY + AND $session_id != '' + AND notEmpty({tool_expr_where}) + GROUP BY session_id + HAVING intent_calls > 0 +) +ORDER BY cityHash64(session_id) +LIMIT {max_sessions_per_tool} BY tool +LIMIT {max_rows} +""" + + +def fetch_tools_by_session( + team: Team, + lookback_days: int = DEFAULT_LOOKBACK_DAYS, + max_sessions_per_tool: int = MIN_SESSIONS_PER_TOOL, +) -> dict[str, set[str]]: + """Return ``{tool: {intent-bearing session_ids}}``, capped per tool. + + Each tool gets its own deterministic cityHash pool, so a tool's presence in + the corpus is independent of the team's total session count. The pool only + has to cover the tool's floor — ``select_corpus_sessions`` spends whatever + budget is left on the uniform sample. + """ + query = parse_select( + _SESSION_TOOLS_SQL, + placeholders={ + "event": ast.Constant(value=MCP_TOOL_CALL_EVENT), + "tool_expr": parse_expr(EFFECTIVE_TOOL_SQL), + "tool_expr_where": parse_expr(EFFECTIVE_TOOL_SQL), + "lookback_days": ast.Constant(value=lookback_days), + "max_tool_len": ast.Constant(value=MAX_TOOL_NAME_LENGTH), + "max_sessions_per_tool": ast.Constant(value=max_sessions_per_tool), + "max_distinct_tools": ast.Constant(value=MAX_DISTINCT_TOOLS_PER_SESSION_BUCKET), + "max_rows": ast.Constant(value=MAX_QUERY_ROWS), + }, + ) + rows = _run_corpus_query(team, query) + if len(rows) >= MAX_QUERY_ROWS: + # The row cap hit before every tool's pool came back, so some tool's + # bucket is short of its floor. Say so rather than let the missing tool + # read as a traffic change. + logger.warning( + "mcpa.intent_clustering.tool_buckets_truncated", + team_id=team.id, + max_rows=MAX_QUERY_ROWS, + max_sessions_per_tool=max_sessions_per_tool, + ) + out: dict[str, set[str]] = defaultdict(set) + for row in rows: + session_id, tool = str(row[0] or ""), str(row[1] or "") + if session_id and tool: + out[tool].add(session_id) + return dict(out) + + +def stratify_session_ids( + tool_sessions: dict[str, set[str]], + min_sessions_per_tool: int, + max_total_sessions: int, +) -> set[str]: + """Choose corpus sessions so no tool is erased by the dominant tools. + + The prior uniform sample drew sessions proportionally to traffic, so in a + window where ``exec``/scout dominate, a low/mid-volume tool's handful of + sessions is statistically dropped and the tool becomes invisible to + clustering. This guarantees each tool keeps up to ``min_sessions_per_tool`` + sessions (its full set when smaller), then fills any remaining budget with + the highest-volume tools, capped at ``max_total_sessions``. + + Deterministic: per-tool session ids take the sorted-prefix, so reruns + re-hit the same ids and the embedding cache. + + Every tool is visited: the budget is apportioned across tools rather than + consumed by the first ones, so a later (or alphabetically later equal-volume) + tool is never skipped, and the result is always within ``max_total_sessions`` + regardless of how the floors interact with the budget. + """ + tools = sorted(tool_sessions) + if not tools or max_total_sessions <= 0: + return set() + + # The budget cannot always give every tool its full floor (many tools x + # floor swamps max_total_sessions), so the effective per-tool floor is + # min(the request, an even share of the budget). Even-share is the only + # allocation that never starves a tool while always fitting the budget. + budget_floor = max(1, max_total_sessions // len(tools)) + floor_size = max(1, min(min_sessions_per_tool, budget_floor)) + + selected: set[str] = set() + # Ascending volume secures scarce tools' floors before dominant tools add + # their own (shared ids are de-duped through ``selected``). + for tool in sorted(tools, key=lambda t: (len(tool_sessions[t]), t)): + selected.update(sorted(tool_sessions[tool])[:floor_size]) + + # Spend the remainder on the highest-volume tools, which hold the bulk of + # the window's calls, keeping the whole result within budget. + room = max_total_sessions - len(selected) + if room > 0: + for tool in sorted(tools, key=lambda t: (-len(tool_sessions[t]), t)): + if room <= 0: + break + for sid in sorted(tool_sessions[tool]): + if room <= 0: + break + if sid not in selected: + selected.add(sid) + room -= 1 + return selected + + +def select_corpus_sessions( + tool_sessions: dict[str, set[str]], + uniform_sample: Sequence[str], + min_sessions_per_tool: int, + max_total_sessions: int, +) -> list[str]: + """The corpus session ids: per-tool floors first, uniform sample for the rest. + + The floors are what keep a low/mid-volume tool in the corpus at any traffic + volume, but they only cover each tool's floor, so on their own they would + shrink the corpus for a team with a handful of tools. Spending the remaining + budget on the uniform hash sample keeps the corpus full size and keeps the + bulk of the window's traffic represented — the per-tool call cap is what + stops that bulk from crowding the intent space later. + + ``uniform_sample`` is also the whole corpus when the per-tool buckets are + unavailable, so a capture or schema gap degrades to the prior behavior + rather than emptying the corpus. + """ + if max_total_sessions <= 0: + return [] + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool, max_total_sessions) + for session_id in uniform_sample: + if len(selected) >= max_total_sessions: + break + selected.add(session_id) + return sorted(selected) + + +@frozen +class ToolCallCapResult: + """Outcome of ``cap_per_tool_call_volume``: the kept rows plus, for each + over-capped tool, how many of its calls were kept vs dropped.""" + + kept_rows: list[tuple[str, str, str, bool]] + per_tool_report: dict[str, dict[str, int]] + + +def cap_per_tool_call_volume( + rows: list[tuple[str, str, str, bool]], + max_calls_per_tool: int, +) -> ToolCallCapResult: + """Down-sample an over-represented tool's raw call rows before attribution. + + Row-level (pre-attribution) so intents and LOCF see the capped population. + Deterministic: keeps an even stride across the tool's rows so the surviving + calls still span the tool's whole session/intent range rather than a prefix. + Each over-capped tool reports how many calls were ``kept`` vs ``dropped``. + """ + tool_row_indexes: dict[str, list[int]] = defaultdict(list) + for idx, (_, tool, _, _) in enumerate(rows): + tool_row_indexes[tool].append(idx) + + keep_indexes: set[int] = set() + report: dict[str, dict[str, int]] = {} + for tool, indexes in tool_row_indexes.items(): + total = len(indexes) + if total <= max_calls_per_tool: + keep_indexes.update(indexes) + continue + # Even stride keeps breadth across the tool's calls. + stride = total / max_calls_per_tool + kept_positions = {int(i * stride) for i in range(max_calls_per_tool)} + kept = {indexes[pos] for pos in kept_positions} + keep_indexes.update(kept) + report[tool] = {"kept": len(kept), "dropped": total - len(kept)} + + kept_rows = [row for idx, row in enumerate(rows) if idx in keep_indexes] + return ToolCallCapResult(kept_rows=kept_rows, per_tool_report=report) + + def fetch_tool_descriptions( team: Team, tools: Collection[str], lookback_days: int = DEFAULT_LOOKBACK_DAYS ) -> dict[str, str]: @@ -1170,6 +1404,16 @@ def build_snapshot( "dropped_tools": dropped_tools, "dropped_overlap_pairs": dropped_pairs, "description_coverage_pct": _pct(described_tools, len(tools)) if tools else None, + # Representation honesty: these per-tool numbers come from a *balanced + # sample*, never the population. Downstream surfaces must warn before + # treating a tool's capture/discovery rate as its true traffic share. + "sampled": True, + "corpus_strategy": "stratified_by_tool", + "sampling_warning": ( + "Intent clusters are computed from a stratified sample of sessions " + "(per-tool floors, dominant tools capped). Per-tool capture and " + "discovery rates are sample statistics, not population totals." + ), } return { @@ -1222,5 +1466,12 @@ def empty_snapshot( "dropped_tools": 0, "dropped_overlap_pairs": 0, "description_coverage_pct": None, + "sampled": True, + "corpus_strategy": "stratified_by_tool", + "sampling_warning": ( + "Intent clusters are computed from a stratified sample of sessions " + "(per-tool floors, dominant tools capped). Per-tool capture and " + "discovery rates are sample statistics, not population totals." + ), }, } diff --git a/products/mcp_analytics/backend/tests/test_intent_clustering.py b/products/mcp_analytics/backend/tests/test_intent_clustering.py index 9c61e9899001..bc96f58a4ac7 100644 --- a/products/mcp_analytics/backend/tests/test_intent_clustering.py +++ b/products/mcp_analytics/backend/tests/test_intent_clustering.py @@ -75,6 +75,10 @@ def _unit(vec: list[float]) -> np.ndarray: return arr / np.linalg.norm(arr) +def _snapshot_record(intent: str, tool: str, count: int) -> IntentRecord: + return IntentRecord(intent_text=intent, frequency=count, tool_counts={tool: count}) + + # cluster_embeddings ------------------------------------------------------ @@ -258,6 +262,20 @@ def test_medoid_is_used_as_cluster_label(self) -> None: assert snapshot["clusters"][0]["label"] == "center" + def test_meta_marks_snapshot_as_sampled_not_population(self) -> None: + # The page presents per-tool numbers as if they were the population; the + # snapshot must carry the sampling/balance metadata so the UI can warn. + records = [_snapshot_record("i1", "exec", 3), _snapshot_record("i2", "query-apm-spans", 1)] + labels = np.array([0, 1], dtype=np.int64) + embeddings = np.array([_unit([1.0, 0.0]), _unit([0.0, 1.0])], dtype=np.float32) + + snapshot = build_snapshot(records, labels, embeddings, calls_by_session={}) + + meta = snapshot["computed_with"] + assert meta["sampled"] is True + assert "corpus_strategy" in meta + assert "sampling_warning" in meta + def test_misaligned_inputs_raise(self) -> None: records = [IntentRecord(intent_text="a", frequency=1, tool_counts={"tool_a": 1})] with pytest.raises(AssertionError): @@ -480,6 +498,171 @@ def test_top_n_keeps_highest_call_count_intents_and_reports_kept_calls(self) -> assert stats.kept_calls == 5 +# stratified corpus --------------------------------------------------------- + + +class TestStratifySessionIds: + """The uniform 0.5% session sample is what erases low/mid-volume tools (the + APM logs/tracing/metrics complaint). Stratifying the *session ids* before + the corpus SQL guarantees every tool keeps a floor of sessions, so no tool + is silently dropped from clustering just because exec/scout dominate.""" + + def test_every_tool_keeps_a_floor_of_sessions(self) -> None: + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions: dict[str, set[str]] = { + "exec": {f"exec-s{i}" for i in range(2000)}, + "query-apm-spans": {f"apm-s{i}" for i in range(30)}, + } + union = set().union(*tool_sessions.values()) + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=400, max_total_sessions=2000) + + # The 30-session APM tool must survive even though a uniform sample of + # ~2000/2030 will statistically drop most of them. + assert {sid for sid in selected if sid.startswith("apm-s")} == set(tool_sessions["query-apm-spans"]) + assert selected.issubset(union) + + def test_total_respects_max_total_sessions(self) -> None: + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions = {f"tool_{t}": {f"tool_{t}-s{i}" for i in range(600)} for t in range(10)} + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=400, max_total_sessions=2000) + + assert len(selected) <= 2000 + + def test_selection_is_deterministic(self) -> None: + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions = {f"tool_{t}": {f"tool_{t}-s{i}" for i in range(50)} for t in range(5)} + + first = stratify_session_ids(tool_sessions, min_sessions_per_tool=20, max_total_sessions=80) + second = stratify_session_ids(tool_sessions, min_sessions_per_tool=20, max_total_sessions=80) + + assert first == second + + def test_scarce_tool_is_kept_entirely(self) -> None: + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions = {"query-metrics": {f"m-s{i}" for i in range(4)}, "exec": {f"e-s{i}" for i in range(3000)}} + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=400, max_total_sessions=2000) + + assert {sid for sid in selected if sid.startswith("m-s")} == tool_sessions["query-metrics"] + + def test_budget_apportions_floors_and_stays_within_total(self) -> None: + # Regression for the over-budget trimming bug: 3 x 250 = 750 must come + # back under a 700 budget, and no tool may be skipped. + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions = {f"tool_{t}": {f"tool_{t}-s{i}" for i in range(300)} for t in range(3)} + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=250, max_total_sessions=700) + + assert len(selected) <= 700 + for t in range(3): + assert any(sid.startswith(f"tool_{t}-s") for sid in selected), f"tool_{t} was skipped" + + def test_late_tool_is_not_skipped_when_floors_fill_the_budget(self) -> None: + # Regression for the break that skipped every tool once earlier floors + # hit the budget: a late, higher-volume tool must still get a floor. + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + tool_sessions: dict[str, set[str]] = { + "alpha": {f"a-s{i}" for i in range(500)}, + "beta": {f"b-s{i}" for i in range(500)}, + "zeta": {f"z-s{i}" for i in range(1000)}, + } + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=400, max_total_sessions=900) + + assert any(sid.startswith("z-s") for sid in selected) + assert len(selected) <= 900 + + def test_never_exceeds_budget_when_many_tools_share_many_sessions(self) -> None: + # 10 tools each with 400 shared sessions: sum of floors (4000) far + # exceeds the budget even after de-dup (shared ids), so any overshoot + # must be trimmed. + from products.mcp_analytics.backend.intent_clustering import stratify_session_ids + + shared = {f"s{i}" for i in range(400)} + tool_sessions = {f"tool_{t}": set(shared) for t in range(10)} + + selected = stratify_session_ids(tool_sessions, min_sessions_per_tool=400, max_total_sessions=1500) + + assert len(selected) <= 1500 + + +class TestSelectCorpusSessions: + """The per-tool buckets only carry each tool's floor, so the rest of the + corpus budget is filled from the uniform hash sample. The top-up must never + displace a floor session — that would undo the stratification — and must + still produce a full-size corpus for a team with only a couple of tools.""" + + def test_floors_survive_a_uniform_sample_larger_than_the_budget(self) -> None: + from products.mcp_analytics.backend.intent_clustering import select_corpus_sessions + + tool_sessions = {"query-metrics": {"m-s0", "m-s1"}} + uniform = [f"exec-s{i}" for i in range(50)] + + selected = select_corpus_sessions(tool_sessions, uniform, min_sessions_per_tool=400, max_total_sessions=10) + + assert {"m-s0", "m-s1"}.issubset(selected) + assert len(selected) == 10 + + def test_uniform_sample_is_the_whole_corpus_when_buckets_are_unavailable(self) -> None: + from products.mcp_analytics.backend.intent_clustering import select_corpus_sessions + + uniform = [f"exec-s{i}" for i in range(30)] + + selected = select_corpus_sessions({}, uniform, min_sessions_per_tool=400, max_total_sessions=10) + + assert len(selected) == 10 + assert set(selected).issubset(uniform) + + def test_budget_already_filled_by_floors_admits_no_top_up(self) -> None: + from products.mcp_analytics.backend.intent_clustering import select_corpus_sessions + + tool_sessions = {f"tool_{t}": {f"tool_{t}-s{i}" for i in range(3)} for t in range(4)} + + selected = select_corpus_sessions(tool_sessions, ["uniform-s0"], min_sessions_per_tool=3, max_total_sessions=12) + + assert "uniform-s0" not in selected + assert len(selected) == 12 + + +class TestCapPerToolCallVolume: + """After sampling, a single high-volume tool (exec) must not be allowed to + occupy the whole intent corpus; cap its attributed calls so mid/low tools + retain enough signal to cluster.""" + + def test_caps_overrepresented_tool_and_reports_it(self) -> None: + from products.mcp_analytics.backend.intent_clustering import cap_per_tool_call_volume + + rows = [("s1", "exec", "operate", False)] * 100 + [("s2", "query-logs", "tail logs", False)] * 2 + + result = cap_per_tool_call_volume(rows, max_calls_per_tool=10) + + per_tool_count: dict[str, int] = {} + for _, tool, _, _ in result.kept_rows: + per_tool_count[tool] = per_tool_count.get(tool, 0) + 1 + assert per_tool_count["exec"] <= 10 + # low-volume tool is untouched + assert per_tool_count["query-logs"] == 2 + assert result.per_tool_report["exec"]["dropped"] >= 90 + + def test_deterministic_about_which_calls_survive(self) -> None: + from products.mcp_analytics.backend.intent_clustering import cap_per_tool_call_volume + + rows = [("s1", "exec", f"op {i}", False) for i in range(50)] + + first = cap_per_tool_call_volume(rows, max_calls_per_tool=10) + second = cap_per_tool_call_volume(rows, max_calls_per_tool=10) + + assert first == second + + # compute_cluster_flows ---------------------------------------------------- @@ -999,6 +1182,39 @@ def test_advertised_catalog_is_bounded_per_list_and_per_session(self) -> None: assert len(advertised["session-chatty"]) <= MAX_ADVERTISED_LIST_EVENTS_PER_SESSION assert len(advertised["session-union"]) == MAX_ADVERTISED_TOOLS_PER_SESSION + def test_tools_by_session_buckets_intent_bearing_sessions_by_effective_tool(self) -> None: + # session-a carries an intent; session-quiet does not, so its tools must + # not buckify it. The exec wrapper resolves to the inner effective tool. + self._seed_tool_call("session-a", "query-logs", intent="tail error logs") + self._seed_tool_call("session-a", "exec", intent="tail error logs", exec_tool_name="query-apm-spans") + self._seed_tool_call("session-quiet", "query-metrics") + flush_persons_and_events() + + buckets = intent_clustering.fetch_tools_by_session(self.team) + + assert buckets.get("query-logs") == {"session-a"} + # the exec-wrapped call buckets under its inner tool + assert buckets.get("query-apm-spans") == {"session-a"} + assert "session-quiet" not in buckets.get("query-metrics", set()) + + def test_tool_buckets_are_capped_per_tool_not_globally(self) -> None: + # A global cap on candidate sessions re-erases the low-volume tool the + # per-tool floor exists to protect: at high total volume its sessions + # fall outside the hash-ordered cut before any bucketing happens, so the + # floor has nothing left to keep. The cap has to apply per tool. + for i in range(12): + self._seed_tool_call(f"exec-s{i}", "exec", intent="operate the thing") + for i in range(2): + self._seed_tool_call(f"metrics-s{i}", "query-metrics", intent="check p99 latency") + flush_persons_and_events() + + buckets = intent_clustering.fetch_tools_by_session(self.team, max_sessions_per_tool=3) + + assert buckets["query-metrics"] == {"metrics-s0", "metrics-s1"} + # exec is capped at the same number, so the two buckets together hold + # more sessions than the cap — the pool was never cut globally. + assert len(buckets["exec"]) == 3 + def test_window_stats_count_calls_intents_and_sessions(self) -> None: self._seed_tool_call("session-a", "execute_sql", intent="find slow queries") self._seed_tool_call("session-a", "query_trends") diff --git a/products/metrics/backend/diagnostics.py b/products/metrics/backend/diagnostics.py index 122eb6ec620a..5f48fa03624e 100644 --- a/products/metrics/backend/diagnostics.py +++ b/products/metrics/backend/diagnostics.py @@ -234,6 +234,13 @@ def decompose_bucket( for key in ordered_keys[:max_series]: samples = grouped[key] service_name, labels, resource_labels = identities[key] + if plan.temporal is TemporalReducer.POOLED_SAMPLES: + series_value = None + else: + # Normalized the same way as the bucket's total, so the series + # a reader adds up still reach the number they are explaining. + reduced = reduce_temporal(reduction_input[key], plan.temporal) + series_value = None if reduced is None else reduced / plan.divisor breakdown.append( MetricSeriesBreakdown( service_name=service_name, @@ -245,11 +252,7 @@ def decompose_bucket( ), sample_count=len(samples), samples_truncated=len(samples) > max_samples_per_series, - # Normalized the same way as the bucket's total, so the series - # a reader adds up still reach the number they are explaining. - value=None - if plan.temporal is TemporalReducer.POOLED_SAMPLES - else reduce_temporal(reduction_input[key], plan.temporal) / plan.divisor, + value=series_value, ) ) diff --git a/products/metrics/backend/fundamentals.py b/products/metrics/backend/fundamentals.py index fc3ceaf14a00..5fa5753ce4b5 100644 --- a/products/metrics/backend/fundamentals.py +++ b/products/metrics/backend/fundamentals.py @@ -176,11 +176,24 @@ def _deduped_in_time_order(samples: Sequence[Sample]) -> list[Sample]: return list(by_timestamp.values()) -def reduce_temporal(samples: Sequence[Sample], reducer: TemporalReducer) -> float: - """Collapse one series' samples to that series' value for the bucket.""" +def reduce_temporal(samples: Sequence[Sample], reducer: TemporalReducer) -> float | None: + """Collapse one series' samples to that series' value for the bucket. + + Returns None when the value is unknowable: a lone cumulative reading has + no predecessor to diff against, and 0 would read as a flat counter. + """ if reducer in (TemporalReducer.NONE, TemporalReducer.POOLED_SAMPLES): raise ValueError(f"{reducer!r} has no single per-series value; apply it through a plan") ordered = _deduped_in_time_order(samples) + if reducer == TemporalReducer.INCREASE: + # A reading below its predecessor means the counter restarted, and the + # post-restart reading is itself the increase. + if len(ordered) < 2: + return None + total = 0.0 + for previous, current in zip(ordered, ordered[1:]): + total += current.value - previous.value if current.value >= previous.value else current.value + return total if not ordered: return 0.0 @@ -190,14 +203,6 @@ def reduce_temporal(samples: Sequence[Sample], reducer: TemporalReducer) -> floa return sum(sample.value for sample in ordered) if reducer == TemporalReducer.AVG_OVER_TIME: return sum(sample.value for sample in ordered) / len(ordered) - if reducer == TemporalReducer.INCREASE: - # The first sample's history is unknown, so it contributes nothing. A - # reading below its predecessor means the counter restarted, and the - # post-restart reading is itself the increase. - total = 0.0 - for previous, current in zip(ordered, ordered[1:]): - total += current.value - previous.value if current.value >= previous.value else current.value - return total raise ValueError(f"Unsupported temporal reducer: {reducer!r}") @@ -247,7 +252,10 @@ def apply_plan(series_samples: Mapping[K, Sequence[Sample]], plan: ReductionPlan sample.value for samples in series_samples.values() for sample in _deduped_in_time_order(samples) ] else: - per_series_values = [reduce_temporal(samples, plan.temporal) for samples in series_samples.values() if samples] + # An unknowable series value contributes nothing rather than a fake 0, + # and a bucket holding only unknowns has no value at all. + reduced = (reduce_temporal(samples, plan.temporal) for samples in series_samples.values() if samples) + per_series_values = [value for value in reduced if value is not None] value = reduce_spatial(per_series_values, plan.spatial, quantile=plan.quantile) # An empty bucket has no number, and normalizing None would invent one. return value if value is None else value / plan.divisor diff --git a/products/metrics/backend/metric_query_runner.py b/products/metrics/backend/metric_query_runner.py index db3c9941d0eb..17e60eeba3d9 100644 --- a/products/metrics/backend/metric_query_runner.py +++ b/products/metrics/backend/metric_query_runner.py @@ -42,10 +42,12 @@ # Widest queryable range. Counter/histogram queries scan raw samples within # the range on the ClickHouse cluster shared with the live logs/traces -# products, so the span has to be bounded. Those two also scan -# `counter_lookback(interval)` before `date_from` for a predecessor sample; -# the bound stays on the requested range, since the extra reach costs at most -# one more daily partition and returns no extra rows. +# products, so the span has to be bounded. The bound stays on the requested +# range: `date_from` snaps back to its bucket boundary and the counter and +# histogram scans reach a further `counter_lookback(interval)` for a +# predecessor sample, so the scan exceeds the request by under one interval +# step plus the lookback (up to two weeks of extra daily partitions at the +# `week` interval, a single one on the common sub-day charts). MAX_QUERY_SPAN = dt.timedelta(days=31) # These run on the shared logs cluster; cap how much one query may read. @@ -260,6 +262,34 @@ def _interval_step(name: str) -> dt.timedelta: raise ValueError(f"Unknown interval: {name!r}") +# The grids `toStartOfInterval` produces: intervals count from the epoch, +# except weeks, which count from a Monday. +_EPOCH = dt.datetime(1970, 1, 1, tzinfo=dt.UTC) +_WEEK_EPOCH = dt.datetime(1970, 1, 5, tzinfo=dt.UTC) + + +def _align_to_interval(timestamp: dt.datetime, interval: str) -> dt.datetime: + """Floor `timestamp` onto the bucket grid `toStartOfInterval` uses. + + The bucket labels come from `toStartOfInterval(sample_timestamp)`, so a + `date_from` inside a bucket would make that first bucket partial: labelled + as the whole interval but covering only the slice after `date_from`. Every + query scans and clips from this floor instead, so the first bucket holds + its full interval. Relative ranges like "-1h" resolve to now-minus-offset + with second precision, which makes the unaligned case the normal one. + + Not `posthog.interval_specs.align`: that grid honors the team's + `week_start_day` and lacks the sub-hour steps, where `toStartOfInterval` + always counts weeks from Monday — the two would disagree exactly where + agreement with the SQL is the point. + """ + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=dt.UTC) + epoch = _WEEK_EPOCH if interval == "week" else _EPOCH + step = _interval_step(interval) + return epoch + ((timestamp - epoch) // step) * step + + # Prometheus's default lookback delta. One interval step on its own is not # enough when the scrape interval is coarser than the bucket — a 60s scrape on # a `second` or `minute` chart — and `metrics1` is partitioned by day with @@ -273,9 +303,9 @@ def counter_lookback(interval: str) -> dt.timedelta: Those aggregations diff each sample against the one before it, so the last sample *outside* the requested range is an input to the first bucket inside - it. Without it the first bucket diffs against nothing and plots 0 - (histograms drop the point instead). The pre-range rows are cut again - before bucketing, so the returned grid is exactly the requested range. + it. Without it the first bucket diffs against nothing and is dropped as + uncomputable. The pre-range rows are cut again before bucketing, so the + returned grid is exactly the requested range. `diagnostics.decompose_bucket` reads its raw samples over the same window through this helper: a shorter reach there would find a different @@ -371,11 +401,13 @@ def __init__( self.team = team self.metric_name = metric_name self.aggregation = aggregation - self.date_from = date_from + self.interval = interval or _pick_interval(date_from, date_to) + # Validation above bounds the requested range; the scan then starts at + # the bucket boundary so the first bucket covers its whole interval. + self.date_from = _align_to_interval(date_from, self.interval) self.date_to = date_to self.filters = tuple(filters) self.group_by = tuple(group_by) - self.interval = interval or _pick_interval(date_from, date_to) self.quantile = quantile self.metric_type = metric_type @@ -559,8 +591,10 @@ def _build_counter_query(self) -> ast.SelectQuery: - cumulative temporality: contribution = value - prev, clamped for counter resets (value < prev means the counter restarted, so the post-reset absolute value IS the increase); a sample with no - predecessor within `counter_lookback` contributes 0 (its history is - unknown). + predecessor within `counter_lookback` has an unknowable increase, so + it contributes NULL, and a bucket where nothing was computable is + dropped rather than plotted as 0 (the histogram path drops such + buckets too). - delta temporality: each sample already is the increase, so it contributes its own value. @@ -586,7 +620,7 @@ def _build_counter_query(self) -> ast.SelectQuery: resource_attributes AS resource_attributes, multiIf( aggregation_temporality = 'delta', value, - isNull(prev_value), 0.0, + isNull(prev_value), NULL, value >= assumeNotNull(prev_value), value - assumeNotNull(prev_value), value ) AS contribution @@ -613,6 +647,7 @@ def _build_counter_query(self) -> ast.SelectQuery: ) WHERE sample_timestamp >= {date_from} GROUP BY time + HAVING isNotNull(value) ORDER BY time ASC LIMIT {row_limit} """, diff --git a/products/metrics/backend/tests/test_diagnostics.py b/products/metrics/backend/tests/test_diagnostics.py index 8ea4ed7c835f..6d4dd5288662 100644 --- a/products/metrics/backend/tests/test_diagnostics.py +++ b/products/metrics/backend/tests/test_diagnostics.py @@ -140,6 +140,30 @@ def test_cumulative_counter_increase_diffs_within_the_series(self) -> None: # +20, then a restart whose post-reset reading is itself the increase. assert decomposition.reference_value == 25.0 + def test_lone_cumulative_sample_has_no_increase_on_either_side(self) -> None: + seed_metric( + team_id=self.team.pk, + metric_name="bytes_total", + metric_type="sum", + aggregation_temporality="cumulative", + is_monotonic=True, + points=[(BUCKET, 100.0)], + ) + + decomposition = decompose_bucket( + team=self.team, + metric_name="bytes_total", + aggregation="increase", + bucket_start=BUCKET, + interval="minute_5", + ) + + # The sample's history is unknown, so both the reference and the chart + # return no value — a 0 on either side would fabricate a flat counter. + assert decomposition.reference_value is None + assert decomposition.actual_value is None + assert decomposition.agrees is True + def test_empty_bucket_reports_no_series_rather_than_zero(self) -> None: decomposition = decompose_bucket( team=self.team, diff --git a/products/metrics/backend/tests/test_fundamentals.py b/products/metrics/backend/tests/test_fundamentals.py index f595767a8294..5a2bdc5603e9 100644 --- a/products/metrics/backend/tests/test_fundamentals.py +++ b/products/metrics/backend/tests/test_fundamentals.py @@ -87,8 +87,9 @@ def test_increase_corrects_counter_reset(self) -> None: # 100 -> 120 is +20; the drop to 5 is a restart, so 5 itself is the increase; 5 -> 25 is +20. assert reduce_temporal(_samples(100, 120, 5, 25), TemporalReducer.INCREASE) == 45 - def test_increase_ignores_history_before_the_first_sample(self) -> None: - assert reduce_temporal(_samples(100), TemporalReducer.INCREASE) == 0 + def test_increase_of_a_lone_sample_is_unknown_not_zero(self) -> None: + # One reading has no predecessor to diff against; 0 would read as "flat". + assert reduce_temporal(_samples(100), TemporalReducer.INCREASE) is None def test_avg_over_time_keeps_the_whole_bucket_not_just_the_tail(self) -> None: # A queue that spiked to 240 and settled at 8 did not average 8. @@ -119,6 +120,14 @@ def test_empty_bucket_has_no_value(self, _name: str, reducer: SpatialReducer) -> # returned 0 here would report every empty bucket as a disagreement. assert reduce_spatial([], reducer) is None + def test_unknown_series_values_drop_out_rather_than_zeroing_the_bucket(self) -> None: + plan = plan_reduction(aggregation="increase", metric_type="sum", temporality="cumulative") + # A lone-sample series adds nothing to the total, and a bucket holding + # only such series has no value at all — mirroring the runner, which + # drops the bucket instead of plotting 0. + assert apply_plan({"a": _samples(100), "b": _samples(10, 25)}, plan) == 15.0 + assert apply_plan({"a": _samples(100)}, plan) is None + class TestPooledQuantile: def test_percentile_reads_the_samples_rather_than_one_value_per_series(self) -> None: diff --git a/products/metrics/backend/tests/test_metric_query_runner.py b/products/metrics/backend/tests/test_metric_query_runner.py index 7a19940b41c8..1d41eaeb76a3 100644 --- a/products/metrics/backend/tests/test_metric_query_runner.py +++ b/products/metrics/backend/tests/test_metric_query_runner.py @@ -22,7 +22,9 @@ from products.metrics.backend.facade.enums import AttributeScope, FilterOp, MetricAggregation from products.metrics.backend.formula import evaluate, parse_formula from products.metrics.backend.metric_query_runner import ( + _INTERVAL_LADDER, MetricQueryRunner, + _align_to_interval, _histogram_quantile, _pick_interval, attribute_field, @@ -46,6 +48,33 @@ def test_pick_interval(self, _name: str, delta: dt.timedelta, expected: str) -> assert _pick_interval(start, start + delta) == expected +class TestAlignToInterval(ClickhouseTestMixin, APIBaseTest): + @parameterized.expand([(name,) for name, _, _ in _INTERVAL_LADDER]) + def test_matches_clickhouse_bucket_boundaries(self, interval: str) -> None: + # The runner snaps date_from onto the bucket grid before querying; if + # this floor ever disagrees with toStartOfInterval, first buckets go + # partial again. + awkward = dt.datetime(2026, 3, 11, 17, 47, 33, 123456, tzinfo=dt.UTC) + aligned = _align_to_interval(awkward, interval) + + interval_call = next(expr for name, _, expr in _INTERVAL_LADDER if name == interval) + interval_arg = interval_call.args[0] + assert isinstance(interval_arg, ast.Constant) + interval_sql = f"{interval_call.name}({interval_arg.value})" + ((clickhouse_aligned,),) = sync_execute( + f"SELECT toStartOfInterval(toDateTime64(%(ts)s, 6, 'UTC'), {interval_sql})", + {"ts": awkward.strftime("%Y-%m-%d %H:%M:%S.%f")}, + ) + if not isinstance(clickhouse_aligned, dt.datetime): + # Week intervals come back as a bare Date. + clickhouse_aligned = dt.datetime.combine(clickhouse_aligned, dt.time(), tzinfo=dt.UTC) + elif clickhouse_aligned.tzinfo is None: + clickhouse_aligned = clickhouse_aligned.replace(tzinfo=dt.UTC) + + self.assertEqual(aligned, clickhouse_aligned) + self.assertLessEqual(aligned, awkward) + + class TestMetricQueryRunner(ClickhouseTestMixin, APIBaseTest): CLASS_DATA_LEVEL_SETUP = True @@ -207,6 +236,39 @@ def test_aggregations_run_across_series_not_samples(self, aggregation: str, expe self.assertEqual([row["value"] for row in runner.run()], [expected]) + def test_unaligned_date_from_reads_the_whole_first_bucket(self): + # The viewer's relative presets ("-1h") resolve to now-minus-offset with + # second precision, so date_from usually lands inside a bucket. A series + # whose only report came before date_from but inside that bucket must + # still count — the bucket stands for its whole interval. + anchor = (timezone.now() - dt.timedelta(minutes=30)).replace(second=0, microsecond=0) + seed_metric( + team_id=self.team.id, + metric_name="m1", + points=[(anchor + dt.timedelta(seconds=5), 3.0)], + labels={"pod": "a"}, + ) + seed_metric( + team_id=self.team.id, + metric_name="m1", + points=[(anchor + dt.timedelta(seconds=40), 4.0)], + labels={"pod": "b"}, + ) + + rows = MetricQueryRunner( + team=self.team, + metric_name="m1", + aggregation="sum", + date_from=anchor + dt.timedelta(seconds=20), + date_to=anchor + dt.timedelta(minutes=1), + interval="minute", + ).run() + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["value"], 7.0) + earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) + self.assertEqual(earliest, anchor) + def test_synthetic_original_timestamp_does_not_split_a_series(self): anchor = timezone.now().replace(second=0, microsecond=0) bucket = anchor - dt.timedelta(minutes=5) @@ -821,6 +883,52 @@ def test_first_bucket_diffs_against_the_sample_before_the_range(self, aggregatio earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) self.assertEqual(earliest, self.anchor - dt.timedelta(minutes=1)) + @parameterized.expand( + [ + ("increase", [20.0, 20.0, 20.0]), + ("rate", [20.0 / 60.0, 20.0 / 60.0, 20.0 / 60.0]), + ] + ) + def test_unaligned_date_from_still_charts_a_complete_first_bucket(self, aggregation: str, expected: list[float]): + # date_from usually lands inside a bucket (relative presets resolve to + # now-minus-offset with second precision). The first bucket must cover + # its whole interval, not just the slice after date_from. + self._seed_counter([(self.anchor + dt.timedelta(seconds=s), 100.0 + s / 3.0) for s in range(-60, 181, 15)]) + rows = self._run( + aggregation, + date_from=self.anchor + dt.timedelta(seconds=20), + date_to=self.anchor + dt.timedelta(minutes=3), + ) + for row, expected_value in zip(rows, expected): + self.assertAlmostEqual(row["value"], expected_value) + self.assertEqual(len(rows), len(expected)) + earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) + self.assertEqual(earliest, self.anchor) + + def test_bucket_with_no_computable_increase_is_dropped_not_zero(self): + # Scraped every 10 minutes: the first in-range sample's predecessor sits + # beyond counter_lookback, so its increase is unknowable. Unknown must + # be a missing point, not a plotted 0 — the histogram path already + # drops such buckets. + start = self.anchor - dt.timedelta(minutes=self.anchor.minute % 5) + self._seed_counter( + [ + (start - dt.timedelta(minutes=10), 100.0), + (start, 200.0), + (start + dt.timedelta(minutes=10), 300.0), + (start + dt.timedelta(minutes=20), 400.0), + ] + ) + rows = self._run( + "increase", + date_from=start, + date_to=start + dt.timedelta(minutes=30), + interval="minute_5", + ) + self.assertEqual([row["value"] for row in rows], [100.0, 100.0]) + earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) + self.assertEqual(earliest, start + dt.timedelta(minutes=10)) + def test_rate_divides_by_bucket_seconds(self): self._seed_counter( [ @@ -1057,6 +1165,29 @@ def test_first_bucket_diffs_against_the_histogram_before_the_range(self): earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) self.assertEqual(earliest, self.anchor - dt.timedelta(minutes=1)) + def test_unaligned_date_from_keeps_the_first_buckets_full_distribution(self): + # Growth recorded before date_from but inside the first bucket is part + # of that bucket's distribution; clipping at date_from skews the + # quantile toward whatever happened to grow last. + self._seed_histogram( + [ + (self.anchor, [100, 100, 100, 0]), + (self.anchor + dt.timedelta(seconds=20), [110, 100, 100, 0]), + (self.anchor + dt.timedelta(seconds=40), [110, 110, 100, 0]), + ], + temporality="cumulative", + ) + rows = self._run( + 0.5, + date_from=self.anchor + dt.timedelta(seconds=30), + date_to=self.anchor + dt.timedelta(minutes=1), + ) + self.assertEqual(len(rows), 1) + # Window contribution [10, 10, 0, 0]: p50 sits in the first bucket. + self.assertAlmostEqual(rows[0]["value"], 0.1) + earliest = dt.datetime.fromisoformat(rows[0]["time"]).astimezone(dt.UTC) + self.assertEqual(earliest, self.anchor) + def test_mismatched_bounds_raise(self): self._seed_histogram([(self.anchor + dt.timedelta(seconds=0), [1, 1, 1, 0])], temporality="delta") self._seed_histogram( diff --git a/products/metrics/frontend/components/MetricsOverview.tsx b/products/metrics/frontend/components/MetricsOverview.tsx index ab8d9dc94398..8d84ba911b64 100644 --- a/products/metrics/frontend/components/MetricsOverview.tsx +++ b/products/metrics/frontend/components/MetricsOverview.tsx @@ -1,6 +1,6 @@ import { useActions, useValues } from 'kea' -import { LemonBanner, LemonTable, LemonTag, Link, Spinner } from '@posthog/lemon-ui' +import { LemonBanner, LemonSkeleton, LemonTable, LemonTag, Link } from '@posthog/lemon-ui' import { TZLabel } from 'lib/components/TZLabel' import { dayjs } from 'lib/dayjs' @@ -11,11 +11,58 @@ import { STALE_AFTER_MS, metricsOverviewLogic } from './metricsOverviewLogic' const isStale = (lastSeen: string): boolean => dayjs().diff(dayjs(lastSeen)) > STALE_AFTER_MS -const OverviewStat = ({ label, value, caption }: { label: string; value: number; caption: string }): JSX.Element => ( +// Shared by the loaded cards and their placeholders, so a rename cannot make the +// labels change as the data lands. +const STAT_LABELS = ['Services', 'Metric names', 'Active series'] as const + +// Header text only, so the placeholder table has the same columns as the real one. +const SERVICE_COLUMN_TITLES = ['Service', 'Metrics', 'Active series', 'Last seen'] + +// `null` renders the placeholder. One component for both states, so the loading +// card cannot drift from the loaded one and change size when the data lands. +const OverviewStat = ({ + label, + value, + caption, +}: { + label: string + value: number | null + caption: string | null +}): JSX.Element => (
- {humanFriendlyNumber(value)} + {value === null ? ( + + ) : ( + {humanFriendlyNumber(value)} + )} {label} - {caption} + {caption === null ? ( + + ) : ( + {caption} + )} +
+) + +// The window length arrives with the data, so the captions are placeholders too +// rather than a hardcoded guess that flashes if the server default ever changes. +const MetricsOverviewSkeleton = (): JSX.Element => ( +
+ +
+ {STAT_LABELS.map((label) => ( + + ))} +
+ ({ + title, + align: title === 'Metrics' || title === 'Active series' ? 'right' : undefined, + }))} + />
) @@ -65,11 +112,7 @@ export const MetricsOverview = (): JSX.Element => { const { viewService } = useActions(metricsOverviewLogic) if (!overview) { - return ( -
- -
- ) + return } const windowHours = Math.round(overview.lookback_seconds / 3600) @@ -79,9 +122,20 @@ export const MetricsOverview = (): JSX.Element => {
- - - + {STAT_LABELS.map((label) => ( + + ))}
Experiment: +def create_experiment(team: Team, flag_key: str, created_by: User | None = None) -> Experiment: """A launched-enough experiment with a multivariate flag, for targeting tests.""" flag = FeatureFlag.objects.create( team=team, key=flag_key, filters={"groups": [{"properties": [], "rollout_percentage": 100}]}, ) - return Experiment.objects.create(team=team, name=f"exp-{flag_key}", feature_flag=flag) + return Experiment.objects.create(team=team, name=f"exp-{flag_key}", feature_flag=flag, created_by=created_by) diff --git a/products/replay_vision/backend/tests/test_access_control.py b/products/replay_vision/backend/tests/test_access_control.py index 71b642a3b7db..befff1b569a9 100644 --- a/products/replay_vision/backend/tests/test_access_control.py +++ b/products/replay_vision/backend/tests/test_access_control.py @@ -224,7 +224,7 @@ def test_experiment_targeting_rejects_an_experiment_the_caller_cannot_view(self) def test_experiment_targeting_hidden_from_a_viewer_without_experiment_access(self) -> None: # A scanner is viewable at a coarser grain than its targeted experiment; a viewer who can't # access the experiment must not learn its id or variants from the scanner payload. - experiment = create_experiment(self.team, "hidden-flag") + experiment = create_experiment(self.team, "hidden-flag", created_by=self.user) targeting = {"experiment_id": experiment.id, "variant": "test"} scanner = self._create_scanner(name="targeted", experiment_targeting=targeting) self._set_resource_default("replay_scanner", "viewer") @@ -245,7 +245,7 @@ def test_save_by_a_viewer_denied_the_experiment_keeps_the_targeting(self) -> Non # The API redacts experiment_targeting to null for such an editor, and the editor form # writes the whole object back on save. Without the write-side guard, renaming the scanner # would silently clear targeting the caller can't even see. - experiment = create_experiment(self.team, "hidden-flag") + experiment = create_experiment(self.team, "hidden-flag", created_by=self.user) targeting = {"experiment_id": experiment.id, "variant": "test"} scanner = self._create_scanner(name="targeted", experiment_targeting=targeting) self._set_resource_default("replay_scanner", "editor") @@ -292,7 +292,7 @@ def test_experiment_id_filter_returns_no_matches_for_an_inaccessible_experiment( # Guards the ?experiment_id= disclosure: a scanner-viewer who can't access the experiment must # not confirm a scanner targets it via the filter's match count. Distinct code path from the # serializer redaction above — the scanner is hidden from the list entirely, not just nulled. - experiment = create_experiment(self.team, "hidden-flag") + experiment = create_experiment(self.team, "hidden-flag", created_by=self.user) targeting = {"experiment_id": experiment.id, "variant": "test"} self._create_scanner(name="targeted", experiment_targeting=targeting) self._set_resource_default("replay_scanner", "viewer") diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/arrow_utils.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/arrow_utils.py index 7bd5faaada16..94b349b1973b 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/arrow_utils.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/arrow_utils.py @@ -5,7 +5,7 @@ import uuid import decimal import datetime -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterable, Iterator, Sequence from functools import _make_key, wraps from ipaddress import IPv4Address, IPv6Address from typing import TYPE_CHECKING, Any, Literal, Optional, cast @@ -228,7 +228,11 @@ def _time_to_seconds(value: datetime.time) -> float: return value.hour * 3600 + value.minute * 60 + value.second + value.microsecond / 1_000_000 -def evolve_pyarrow_schema(incoming_table: pa.Table, delta_schema: deltalake.Schema | None) -> pa.Table: +def evolve_pyarrow_schema( + incoming_table: pa.Table, + delta_schema: deltalake.Schema | None, + merge_key_columns: Sequence[str] | None = None, +) -> pa.Table: # First pass: normalize types that Delta write path does not handle well. for column_name in incoming_table.column_names: incoming_column = incoming_table.column(column_name) @@ -285,6 +289,7 @@ def evolve_pyarrow_schema(incoming_table: pa.Table, delta_schema: deltalake.Sche # Second pass: align with existing Delta table schema. delta_arrow_schema = pyarrow_schema_from_arrow_exportable(delta_schema) + merge_keys = {_fold_column_name_for_match(name) for name in merge_key_columns or []} for delta_field in delta_arrow_schema: if delta_field.name not in incoming_table.schema.names: new_column_data = ( @@ -356,6 +361,28 @@ def evolve_pyarrow_schema(incoming_table: pa.Table, delta_schema: deltalake.Sche incoming_table = incoming_table.set_column( incoming_table.schema.get_field_index(delta_field.name), delta_field.name, parsed_timestamps ) + elif ( + _fold_column_name_for_match(delta_field.name) in merge_keys + and (pa.types.is_binary(delta_field.type) or pa.types.is_large_binary(delta_field.type)) + and (pa.types.is_string(incoming_column.type) or pa.types.is_large_string(incoming_column.type)) + ): + # A table written before `hex_encode_id_binary_columns` stores this key as raw + # bytes while the batch now carries hex text. pyarrow casts string to binary + # without complaint, which would store the hex text as bytes: the merge predicate + # would then match no stored row and re-insert every incoming row. Fail instead, + # so the table is reset and re-synced onto the hex representation. + # + # Only merge keys (primary keys and the partition-key source columns) take this + # path. Every other column casts as before, so a source that legitimately turns a + # non-key binary column into a string column keeps syncing. + raise SchemaColumnTypeChangedException( + f"Source column type changed: merge key '{delta_field.name}' is stored as {delta_field.type} " + f"but now arrives as text ({incoming_column.type}). Reset and fully re-sync this table to " + f"adopt the new type.", + column_name=delta_field.name, + stored_type=delta_field.type, + incoming_type=incoming_column.type, + ) else: try: casted_column = incoming_column.cast(delta_field.type).combine_chunks() @@ -597,6 +624,38 @@ def _is_id_like_column(column_name: str, primary_keys: Sequence[str] | None) -> return any(lowered == key.lower() for key in (primary_keys or [])) +def _hex_arrays_or_report( + value_chunks: Iterable[Sequence[bytes | None]], + column_name: str, + binary_reporter: Optional[BinaryColumnReporter], + hex_type: pa.DataType, +) -> Optional[list[pa.Array]]: + """Lowercase-hex strings for one binary column, or None when the values can't be converted. + + Takes the column one chunk at a time so the Python `bytes` and `str` objects of a whole + column are never live at once — an Arrow-native source hands over batches far larger than + the row path's, and this is the only step that leaves Arrow. + + Callers decide what None means: the row path drops the column, the Arrow path leaves it + binary. Both report the same way. + """ + try: + hex_arrays: list[pa.Array] = [ + pa.array([None if value is None else value.hex() for value in chunk], type=hex_type) + for chunk in value_chunks + ] + # pa.ArrowException also catches the 32-bit offset overflow of a chunk whose hex crosses + # 2 GB, which is an ArrowCapacityError and so sits outside ValueError. + except (AttributeError, TypeError, ValueError, pa.ArrowException) as e: + if binary_reporter: + binary_reporter.conversion_failed(column_name, e) + return None + + if binary_reporter: + binary_reporter.converted(column_name) + return hex_arrays + + class BinaryColumnReporter: """Logs each binary column's outcome once per instance lifetime (one sync), because `_process_batch` runs per batch and logging there directly would repeat the same line @@ -629,6 +688,42 @@ def conversion_failed(self, column_name: str, error: Exception) -> None: ) +def hex_encode_id_binary_columns( + table: pa.Table, + primary_keys: Optional[Sequence[str]] = None, + binary_reporter: Optional[BinaryColumnReporter] = None, +) -> pa.Table: + """Convert id-like binary columns of an Arrow-native batch to lowercase hex strings. + + Sources that hand the pipeline Arrow tables (BigQuery, Snowflake, Databricks, MotherDuck) + never reach `_process_batch`, so their binary keys land in Delta as raw bytes, which cannot + be read or joined in HogQL. Same name/primary-key gate as the row path. + + Non-key binary columns stay untouched: this path has always synced them, so dropping them + the way the row path does would delete data these tables already carry. + """ + for index, field in enumerate(table.schema): + if not (pa.types.is_binary(field.type) or pa.types.is_large_binary(field.type)): + continue + if not _is_id_like_column(field.name, primary_keys): + continue + + # Indexed, not by name: a batch carrying the same column name twice makes the name + # lookup raise instead of converting. + column = table.column(index) + # Keep 64-bit offsets where the source column has them: hex doubles the byte length. + hex_type = pa.large_string() if pa.types.is_large_binary(field.type) else pa.string() + hex_arrays = _hex_arrays_or_report( + (chunk.to_pylist() for chunk in column.chunks), field.name, binary_reporter, hex_type + ) + if hex_arrays is None: + continue + + table = table.set_column(index, field.with_type(hex_type), pa.chunked_array(hex_arrays, type=hex_type)) + + return table + + def _convert_uuid_to_string(row: dict) -> dict: return {key: str(value) if isinstance(value, uuid.UUID) else value for key, value in row.items()} @@ -1147,23 +1242,21 @@ def _process_batch( # and incremental merges on the synced table. if pa.types.is_binary(field.type): if _is_id_like_column(str(field_name), primary_keys): - try: - hex_array = pa.array( - [None if s is None else s.hex() for s in _to_list_array(columnar_table_data[field_name])] - ) - except (AttributeError, TypeError, ValueError) as e: - if binary_reporter: - binary_reporter.conversion_failed(str(field_name), e) + hex_arrays = _hex_arrays_or_report( + [_to_list_array(columnar_table_data[field_name])], + str(field_name), + binary_reporter, + pa.string(), + ) + if hex_arrays is None: drop_column_names.add(field_name) else: - columnar_table_data[field_name] = hex_array + columnar_table_data[field_name] = hex_arrays[0] py_type = str unique_types_in_column = {str} arrow_schema = arrow_schema.set( field_index, arrow_schema.field(field_index).with_type(pa.string()) ) - if binary_reporter: - binary_reporter.converted(str(field_name)) else: if binary_reporter: binary_reporter.dropped(str(field_name)) @@ -1405,23 +1498,18 @@ def _convert_to_float_or_none(x: float | str | None) -> float | None: # schemas, or a declared type the values don't match). if issubclass(py_type, bytes): if _is_id_like_column(str(field_name), primary_keys): - try: - hex_array = pa.array( - [None if s is None else s.hex() for s in _to_list_array(columnar_table_data[field_name])] - ) - except (AttributeError, TypeError, ValueError) as e: - if binary_reporter: - binary_reporter.conversion_failed(str(field_name), e) + hex_arrays = _hex_arrays_or_report( + [_to_list_array(columnar_table_data[field_name])], str(field_name), binary_reporter, pa.string() + ) + if hex_arrays is None: drop_column_names.add(field_name) else: - columnar_table_data[field_name] = hex_array + columnar_table_data[field_name] = hex_arrays[0] py_type = str if arrow_schema: arrow_schema = arrow_schema.set( field_index, arrow_schema.field(field_index).with_type(pa.string()) ) - if binary_reporter: - binary_reporter.converted(str(field_name)) else: if binary_reporter: binary_reporter.dropped(str(field_name)) diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/batcher.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/batcher.py index 9896e7f7ec8d..17efabeff4d3 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/batcher.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/batcher.py @@ -8,6 +8,7 @@ from products.warehouse_sources.backend.temporal.data_imports.pipelines.core.arrow_utils import ( BinaryColumnReporter, + hex_encode_id_binary_columns, table_from_py_list, ) from products.warehouse_sources.backend.temporal.data_imports.pipelines.core.table_stats import ( @@ -313,6 +314,9 @@ def _batch(self, item: list[Any] | dict | pa.Table) -> None: # losing data. (In practice sources emit only one item type, never a mix.) if self._buffer: raise Exception("Cannot batch a pa.Table while list/dict rows are buffered; call get_table() first") + # Arrow-native sources skip `_rows_to_table`, so their binary keys are converted here + # instead. + item = hex_encode_id_binary_columns(item, self._primary_keys, self._binary_reporter) if self._coalesce_tables: self._batch_table(item) return diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_batcher.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_batcher.py index 9f8cb11a63b6..598820bbb18c 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_batcher.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_batcher.py @@ -486,3 +486,22 @@ def test_batching_should_yield_when_buffer_not_full_with_incomplete_chunk_set(): expected_table = pa.table({"a": [1, 2, 3]}) assert result_table.equals(expected_table) + + +def test_batching_pa_table_converts_primary_key_binary_column_to_hex(): + batcher = Batcher(logger=mock.MagicMock(), primary_keys=["sk_load"]) + + batcher.batch( + pa.table( + { + "sk_load": pa.array([b"\xbd\xd6\x40", None], type=pa.binary()), + "payload": pa.array([b"\x01", b"\x02"], type=pa.binary()), + } + ) + ) + + result_table = batcher.get_table() + + assert result_table.column("sk_load").to_pylist() == ["bdd640", None] + assert result_table.schema.field("sk_load").type == pa.string() + assert result_table.column("payload").to_pylist() == [b"\x01", b"\x02"] diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_utils.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_utils.py index c610bde3f02e..22df271244a2 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_utils.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/core/test/test_utils.py @@ -28,6 +28,7 @@ apply_enabled_columns_projection, conditional_lru_cache_async, evolve_pyarrow_schema, + hex_encode_id_binary_columns, is_safe_numeric_widening, merge_observed_columns_into_schema_metadata, normalize_table_column_names, @@ -379,6 +380,48 @@ def test_table_from_py_list_keeps_binary_id_column_with_schema(): assert table.column("column").to_pylist() == ["hello", "world"] +@pytest.mark.parametrize( + "column_name,column_type,primary_keys,expected_values,expected_type", + [ + ("id", pa.binary(), None, ["bdd640", None], pa.string()), + ("order_id", pa.binary(), None, ["bdd640", None], pa.string()), + ("sk_load", pa.binary(), ["sk_load"], ["bdd640", None], pa.string()), + ("sk_load", pa.large_binary(), ["sk_load"], ["bdd640", None], pa.large_string()), + ("sk_load", pa.binary(), None, [b"\xbd\xd6\x40", None], pa.binary()), + ("payload", pa.binary(), None, [b"\xbd\xd6\x40", None], pa.binary()), + ], +) +def test_hex_encode_id_binary_columns( + column_name: str, + column_type: pa.DataType, + primary_keys: list[str] | None, + expected_values: list[Any], + expected_type: pa.DataType, +): + table = pa.table({column_name: pa.array([b"\xbd\xd6\x40", None], type=column_type), "other": [1.0, 2.0]}) + + converted = hex_encode_id_binary_columns(table, primary_keys) + + assert converted.column(column_name).to_pylist() == expected_values + assert converted.column("other").to_pylist() == [1.0, 2.0] + assert converted.schema.field(column_name).type == expected_type + + +def test_hex_encode_id_binary_columns_keeps_chunk_order_and_nulls(): + chunked = pa.chunked_array( + [ + pa.array([b"\xbd\xd6\x40", None], type=pa.binary()), + pa.array([None, b"\x01\xff"], type=pa.binary()), + ] + ) + table = pa.table({"id": chunked}) + + converted = hex_encode_id_binary_columns(table) + + assert converted.column("id").to_pylist() == ["bdd640", None, None, "01ff"] + assert converted.schema.field("id").type == pa.string() + + def test_binary_column_reporter_logs_each_column_once_across_batches(): logger = MagicMock() reporter = BinaryColumnReporter(logger) @@ -930,6 +973,34 @@ def test_evolve_pyarrow_schema_whole_valued_floats_cast_into_stored_integer_colu assert evolved_table.column("val").to_pylist() == [10, 20] +@pytest.mark.parametrize( + "merge_key_columns,raises", + [ + (["val"], True), + (None, False), + ], +) +def test_evolve_pyarrow_schema_guards_only_merge_keys_against_hex_text( + merge_key_columns: list[str] | None, raises: bool +): + arrow_table = pa.table( + { + "id": pa.array([1, 2], type=pa.int64()), + "val": pa.array(["01ff", "02ff"], type=pa.string()), + } + ) + delta_schema = deltalake.Schema.from_arrow( + pa.schema(cast(Any, [pa.field("id", pa.int64(), nullable=False), pa.field("val", pa.binary(), nullable=True)])) + ) + + if raises: + with pytest.raises(SchemaColumnTypeChangedException, match="merge key"): + evolve_pyarrow_schema(arrow_table, delta_schema, merge_key_columns=merge_key_columns) + else: + evolved = evolve_pyarrow_schema(arrow_table, delta_schema, merge_key_columns=merge_key_columns) + assert evolved.column("val").to_pylist() == [b"01ff", b"02ff"] + + @pytest.mark.parametrize( "delta_type, incoming_column", [ diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v2/pipeline.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v2/pipeline.py index 751fd75dc8c0..3f24a28e7e8f 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v2/pipeline.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v2/pipeline.py @@ -341,7 +341,16 @@ async def _process_pa_table( pa_table = await setup_partitioning(pa_table, delta_table, self._schema, self._resource, self._logger) - pa_table = evolve_pyarrow_schema(pa_table, delta_table.schema() if delta_table is not None else None) + pa_table = evolve_pyarrow_schema( + pa_table, + delta_table.schema() if delta_table is not None else None, + merge_key_columns=[ + *(self._resource.primary_keys or []), + *(self._schema.partitioning_keys_override or []), + *(self._schema.partitioning_keys or []), + *(self._resource.partition_keys or []), + ], + ) pa_table = _handle_null_columns_with_definitions(pa_table, self._resource) write_type: Literal["incremental", "full_refresh", "append"] = "full_refresh" diff --git a/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/load/processor.py b/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/load/processor.py index 9db501544429..df49be649f3d 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/load/processor.py +++ b/products/warehouse_sources/backend/temporal/data_imports/pipelines/pipeline_v3/load/processor.py @@ -898,7 +898,11 @@ def _process_message_reported( if existing_delta_table is not None: try: - pa_table = evolve_pyarrow_schema(pa_table, existing_delta_table.schema()) + pa_table = evolve_pyarrow_schema( + pa_table, + existing_delta_table.schema(), + merge_key_columns=[*(primary_keys or []), *(export_signal.partition_keys or [])], + ) except SchemaColumnTypeChangedException as e: # A safe numeric widening is mechanically recoverable: stamp reset_pipeline so the # next scheduled sync resets and re-syncs the table, and reword the failure so diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index b286f82f61aa..5954cc97a800 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -8494,7 +8494,7 @@ "system_prompt_hint": "One MCP tool's top callers — calls, error rate, harnesses, person email/name" }, "query-metrics": { - "description": "Query server/infrastructure metrics (OTel- or Prometheus-ingested) as bucketed time series. The response is a list of series — `{labels, points: [{time, value}], metric_name, clause}` — where every series shares one time grid (missing buckets are zero-filled). A single ungrouped query returns exactly one series with empty labels.\n\nAll parameters are nested inside a `query` object. Two request forms:\n\n- **Single metric (shorthand):** set `metricName` (+ `aggregation`, `filters`, `groupBy`).\n- **Multi-clause / formula:** set `clauses: [{name, metricName, aggregation, quantile?, filters?, groupBy?}, ...]` and optionally `formula` (e.g. `\"(a - b) / a\"` over clause names; `+ - * /` and parentheses; division by zero yields 0). With a formula set, only the formula result series are returned.\n\n# Workflow — follow this order every time\n\n1. **Discover names first.** Call `metric-names-list` with a substring (`value`) before querying — metric names must match exactly, and the returned `metric_type` tells you the right aggregation.\n2. **Pick the aggregation by metric type:**\n - `sum` counters (usually `_total`): use `rate` (per-second) or `increase` — both are counter-reset safe and temporality-aware. Do NOT use `sum`/`avg` on cumulative counters; absolute counter values are meaningless.\n - `gauge`: `avg` (typical), `p95`, `sum`.\n - Every aggregation combines across series, never across raw samples, so a result never scales with the scrape rate: `sum`/`avg`/`p95` reduce each series to its latest value in the bucket and combine those, and `count` is the number of series that reported.\n - `histogram`: `histogram_quantile` with `quantile` (e.g. 0.95). All selected series must share one bucket layout — narrow with `filters` if you get a bounds-mismatch error.\n3. **Narrow with filters.** `filters: [{key, op, value, scope?}]`, ANDed. Ops: `eq`, `neq`, `regex`, `not_regex` (RE2). Leave `scope` at its default `auto` unless you know whether the attribute is per-target (`resource`) or per-datapoint (`attribute`). Negative ops also match rows lacking the key, like Prometheus negative matchers.\n4. **Split with groupBy.** `groupBy: [{key}]` returns one series per label value (capped at the 100 largest). The service name is always available — `service_name` and `service.name` both resolve to it in metrics (logs only accept the dotted `service.name`); discover other keys from a sample query's labels or ask the user.\n5. **Control the grid with `interval`.** One of `second, minute, minute_5, minute_15, hour, hour_6, day, week`. Omit to auto-pick (~60 buckets across the range). Use the same interval when comparing windows.\n\n# Investigating an anomaly (\"metric X is rising — why?\")\n\n1. Query the metric over a window that includes the anomaly AND an equal-length healthy baseline before it (one call, auto interval).\n2. Find the onset: the first bucket where the value clearly departs from the baseline range.\n3. Re-query grouped by `service_name` (then by other candidate keys) over the same window to see WHICH series moved — a single label value moving points at the culprit; all moving together points at something shared (upstream dependency, infra).\n4. Use `formula` for normalized comparisons, e.g. error ratio `errors / requests` instead of raw error counts.\n5. Correlate the onset window across signals: query logs (`query-logs`, filtered to the same `service.name` and time window, severity error) and traces (APM span tools, same service/window) to find the cause and its blast radius.\n\nCRITICAL: be minimalist — only include filters/settings essential to the question. Time ranges: `dateFrom` is required, ISO 8601; `dateTo` defaults to now.", + "description": "Query server/infrastructure metrics (OTel- or Prometheus-ingested) as bucketed time series. The response is a list of series — `{labels, points: [{time, value}], metric_name, clause}` — where every series shares one time grid (missing buckets are zero-filled). A single ungrouped query returns exactly one series with empty labels.\n\nAll parameters are nested inside a `query` object. Two request forms:\n\n- **Single metric (shorthand):** set `metricName` (+ `aggregation`, `filters`, `groupBy`).\n- **Multi-clause / formula:** set `clauses: [{name, metricName, aggregation, quantile?, filters?, groupBy?}, ...]` and optionally `formula` (e.g. `\"(a - b) / a\"` over clause names; `+ - * /` and parentheses; division by zero yields 0). With a formula set, only the formula result series are returned.\n\n# Workflow — follow this order every time\n\n1. **Discover names first.** Call `metric-names-list` with a substring (`value`) before querying — metric names must match exactly, and the returned `metric_type` tells you the right aggregation.\n2. **Pick the aggregation by metric type:**\n - `sum` counters (usually `_total`): use `rate` (per-second) or `increase` — both are counter-reset safe and temporality-aware. Do NOT use `sum`/`avg` on cumulative counters; absolute counter values are meaningless.\n - `gauge`: `avg` (typical), `p95`, `sum`.\n - Every aggregation combines across series, never across raw samples, so a result never scales with the scrape rate: `sum`/`avg`/`p95` reduce each series to its latest value in the bucket and combine those, and `count` is the number of series that reported.\n - `histogram`: `histogram_quantile` with `quantile` (e.g. 0.95). All selected series must share one bucket layout — narrow with `filters` if you get a bounds-mismatch error.\n3. **Narrow with filters.** `filters: [{key, op, value, scope?}]`, ANDed. Ops: `eq`, `neq`, `regex`, `not_regex` (RE2). Leave `scope` at its default `auto` unless you know whether the attribute is per-target (`resource`) or per-datapoint (`attribute`). Negative ops also match rows lacking the key, like Prometheus negative matchers.\n4. **Split with groupBy.** `groupBy: [{key}]` returns one series per label value (capped at the 100 largest). The service name is always available — `service_name` and `service.name` both resolve to it in metrics (logs only accept the dotted `service.name`); discover other keys from a sample query's labels or ask the user.\n5. **Control the grid with `interval`.** One of `second, minute, minute_5, minute_15, hour, hour_6, day, week`. Omit to auto-pick (~60 buckets across the range). Use the same interval when comparing windows.\n\n# Investigating an anomaly (\"metric X is rising — why?\")\n\n1. Query the metric over a window that includes the anomaly AND an equal-length healthy baseline before it (one call, auto interval).\n2. Find the onset: the first bucket where the value clearly departs from the baseline range.\n3. Re-query grouped by `service_name` (then by other candidate keys) over the same window to see WHICH series moved — a single label value moving points at the culprit; all moving together points at something shared (upstream dependency, infra).\n4. Use `formula` for normalized comparisons, e.g. error ratio `errors / requests` instead of raw error counts.\n5. Correlate the onset window across signals: query logs (`query-logs`, filtered to the same `service.name` and time window, severity error) and traces (APM span tools, same service/window) to find the cause and its blast radius.\n\nCRITICAL: be minimalist — only include filters/settings essential to the question. Time ranges: `dateFrom` is required, ISO 8601; `dateTo` defaults to now. `dateFrom` snaps down to its bucket boundary, so the first point can be labelled up to one interval earlier than requested but always covers a whole bucket.", "category": "Metrics", "feature": "metrics", "summary": "Query metrics", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index 92508d86d423..57a6ada7a0de 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -9021,7 +9021,7 @@ "system_prompt_hint": "One MCP tool's top callers — calls, error rate, harnesses, person email/name" }, "query-metrics": { - "description": "Query server/infrastructure metrics (OTel- or Prometheus-ingested) as bucketed time series. The response is a list of series — `{labels, points: [{time, value}], metric_name, clause}` — where every series shares one time grid (missing buckets are zero-filled). A single ungrouped query returns exactly one series with empty labels.\n\nAll parameters are nested inside a `query` object. Two request forms:\n\n- **Single metric (shorthand):** set `metricName` (+ `aggregation`, `filters`, `groupBy`).\n- **Multi-clause / formula:** set `clauses: [{name, metricName, aggregation, quantile?, filters?, groupBy?}, ...]` and optionally `formula` (e.g. `\"(a - b) / a\"` over clause names; `+ - * /` and parentheses; division by zero yields 0). With a formula set, only the formula result series are returned.\n\n# Workflow — follow this order every time\n\n1. **Discover names first.** Call `metric-names-list` with a substring (`value`) before querying — metric names must match exactly, and the returned `metric_type` tells you the right aggregation.\n2. **Pick the aggregation by metric type:**\n - `sum` counters (usually `_total`): use `rate` (per-second) or `increase` — both are counter-reset safe and temporality-aware. Do NOT use `sum`/`avg` on cumulative counters; absolute counter values are meaningless.\n - `gauge`: `avg` (typical), `p95`, `sum`.\n - Every aggregation combines across series, never across raw samples, so a result never scales with the scrape rate: `sum`/`avg`/`p95` reduce each series to its latest value in the bucket and combine those, and `count` is the number of series that reported.\n - `histogram`: `histogram_quantile` with `quantile` (e.g. 0.95). All selected series must share one bucket layout — narrow with `filters` if you get a bounds-mismatch error.\n3. **Narrow with filters.** `filters: [{key, op, value, scope?}]`, ANDed. Ops: `eq`, `neq`, `regex`, `not_regex` (RE2). Leave `scope` at its default `auto` unless you know whether the attribute is per-target (`resource`) or per-datapoint (`attribute`). Negative ops also match rows lacking the key, like Prometheus negative matchers.\n4. **Split with groupBy.** `groupBy: [{key}]` returns one series per label value (capped at the 100 largest). The service name is always available — `service_name` and `service.name` both resolve to it in metrics (logs only accept the dotted `service.name`); discover other keys from a sample query's labels or ask the user.\n5. **Control the grid with `interval`.** One of `second, minute, minute_5, minute_15, hour, hour_6, day, week`. Omit to auto-pick (~60 buckets across the range). Use the same interval when comparing windows.\n\n# Investigating an anomaly (\"metric X is rising — why?\")\n\n1. Query the metric over a window that includes the anomaly AND an equal-length healthy baseline before it (one call, auto interval).\n2. Find the onset: the first bucket where the value clearly departs from the baseline range.\n3. Re-query grouped by `service_name` (then by other candidate keys) over the same window to see WHICH series moved — a single label value moving points at the culprit; all moving together points at something shared (upstream dependency, infra).\n4. Use `formula` for normalized comparisons, e.g. error ratio `errors / requests` instead of raw error counts.\n5. Correlate the onset window across signals: query logs (`query-logs`, filtered to the same `service.name` and time window, severity error) and traces (APM span tools, same service/window) to find the cause and its blast radius.\n\nCRITICAL: be minimalist — only include filters/settings essential to the question. Time ranges: `dateFrom` is required, ISO 8601; `dateTo` defaults to now.", + "description": "Query server/infrastructure metrics (OTel- or Prometheus-ingested) as bucketed time series. The response is a list of series — `{labels, points: [{time, value}], metric_name, clause}` — where every series shares one time grid (missing buckets are zero-filled). A single ungrouped query returns exactly one series with empty labels.\n\nAll parameters are nested inside a `query` object. Two request forms:\n\n- **Single metric (shorthand):** set `metricName` (+ `aggregation`, `filters`, `groupBy`).\n- **Multi-clause / formula:** set `clauses: [{name, metricName, aggregation, quantile?, filters?, groupBy?}, ...]` and optionally `formula` (e.g. `\"(a - b) / a\"` over clause names; `+ - * /` and parentheses; division by zero yields 0). With a formula set, only the formula result series are returned.\n\n# Workflow — follow this order every time\n\n1. **Discover names first.** Call `metric-names-list` with a substring (`value`) before querying — metric names must match exactly, and the returned `metric_type` tells you the right aggregation.\n2. **Pick the aggregation by metric type:**\n - `sum` counters (usually `_total`): use `rate` (per-second) or `increase` — both are counter-reset safe and temporality-aware. Do NOT use `sum`/`avg` on cumulative counters; absolute counter values are meaningless.\n - `gauge`: `avg` (typical), `p95`, `sum`.\n - Every aggregation combines across series, never across raw samples, so a result never scales with the scrape rate: `sum`/`avg`/`p95` reduce each series to its latest value in the bucket and combine those, and `count` is the number of series that reported.\n - `histogram`: `histogram_quantile` with `quantile` (e.g. 0.95). All selected series must share one bucket layout — narrow with `filters` if you get a bounds-mismatch error.\n3. **Narrow with filters.** `filters: [{key, op, value, scope?}]`, ANDed. Ops: `eq`, `neq`, `regex`, `not_regex` (RE2). Leave `scope` at its default `auto` unless you know whether the attribute is per-target (`resource`) or per-datapoint (`attribute`). Negative ops also match rows lacking the key, like Prometheus negative matchers.\n4. **Split with groupBy.** `groupBy: [{key}]` returns one series per label value (capped at the 100 largest). The service name is always available — `service_name` and `service.name` both resolve to it in metrics (logs only accept the dotted `service.name`); discover other keys from a sample query's labels or ask the user.\n5. **Control the grid with `interval`.** One of `second, minute, minute_5, minute_15, hour, hour_6, day, week`. Omit to auto-pick (~60 buckets across the range). Use the same interval when comparing windows.\n\n# Investigating an anomaly (\"metric X is rising — why?\")\n\n1. Query the metric over a window that includes the anomaly AND an equal-length healthy baseline before it (one call, auto interval).\n2. Find the onset: the first bucket where the value clearly departs from the baseline range.\n3. Re-query grouped by `service_name` (then by other candidate keys) over the same window to see WHICH series moved — a single label value moving points at the culprit; all moving together points at something shared (upstream dependency, infra).\n4. Use `formula` for normalized comparisons, e.g. error ratio `errors / requests` instead of raw error counts.\n5. Correlate the onset window across signals: query logs (`query-logs`, filtered to the same `service.name` and time window, severity error) and traces (APM span tools, same service/window) to find the cause and its blast radius.\n\nCRITICAL: be minimalist — only include filters/settings essential to the question. Time ranges: `dateFrom` is required, ISO 8601; `dateTo` defaults to now. `dateFrom` snaps down to its bucket boundary, so the first point can be labelled up to one interval earlier than requested but always covers a whole bucket.", "category": "Metrics", "feature": "metrics", "summary": "Query metrics", diff --git a/services/mcp/src/lib/oauth-constants.ts b/services/mcp/src/lib/oauth-constants.ts index 68beffa9b4a0..966f9c7c5748 100644 --- a/services/mcp/src/lib/oauth-constants.ts +++ b/services/mcp/src/lib/oauth-constants.ts @@ -7,6 +7,9 @@ import type { CloudRegion } from '@/tools/types' import packageJson from '../../package.json' +// posthog/auth.py mirrors the "posthog/mcp-server" prefix as MCP_USER_AGENT_MARKER. +// PostHog matches it to apply the org-level MCP read-only policy. If the prefix +// changes, the policy stops matching MCP traffic. Change both sides together. export const USER_AGENT = `posthog/mcp-server; version: ${packageJson.version}` export interface GetUserAgentOptions {