diff --git a/.depot/workflows/ci-backend.yml b/.depot/workflows/ci-backend.yml
index caf1d31c0931..792b389eb42a 100644
--- a/.depot/workflows/ci-backend.yml
+++ b/.depot/workflows/ci-backend.yml
@@ -3085,10 +3085,6 @@ jobs:
# The default ref, which is the PR merge commit. turbo-discover selects the test files
# from this tree, so a checkout of any other ref can lack a file that the selection lists.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- fetch-depth: 1
- lfs: true
- clean: false
- name: Install signal-fanout
shell: bash
run: sudo install -m 0755 .github/scripts/signal-fanout /usr/local/bin/signal-fanout
diff --git a/Dockerfile b/Dockerfile
index d824cc8b24fb..d0b2a17d63a4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,7 @@
# The stages are used to:
#
# - node-base: shared Node.js base with pnpm already provisioned
+# - product-manifests: collect the package.json of each products/* workspace package
# - frontend-build: build the frontend (static assets)
# - sourcemap-upload: upload sourcemaps to PostHog (isolated, no artifacts)
# - node-scripts-build: build plugin transpiler and other Node.js build artifacts
@@ -39,6 +40,15 @@ RUN corepack enable && corepack install
ENV COREPACK_ENABLE_NETWORK=0
+#
+# ---------------------------------------------------------
+#
+# Only the package.json files, so a product source change does not rerun the frontend pnpm install.
+FROM node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 AS product-manifests
+COPY products/ /tmp/products/
+RUN cd /tmp/products && for manifest in */package.json; do install -D -m 644 "$manifest" "/manifests/products/$manifest"; done
+
+
#
# ---------------------------------------------------------
#
@@ -56,11 +66,12 @@ COPY common/tailwind/ common/tailwind/
COPY packages/quill/ packages/quill/
COPY packages/llm-normalizer/ packages/llm-normalizer/
COPY packages/agent/packages/agent-contracts/src/ packages/agent/packages/agent-contracts/src/
-COPY products/ products/
+COPY --from=product-manifests /manifests/products/ products/
COPY docs/onboarding/ docs/onboarding/
RUN --mount=type=cache,id=pnpm,target=/tmp/pnpm-store-v24 \
CI=1 pnpm --filter=@posthog/frontend... install --frozen-lockfile --store-dir /tmp/pnpm-store-v24
+COPY products/ products/
COPY frontend/ frontend/
RUN bin/turbo --filter=@posthog/frontend build
diff --git a/docs/internal/ai-offline-evaluation-reporting.md b/docs/internal/ai-offline-evaluation-reporting.md
index 9afc6c3ed976..31521cacf264 100644
--- a/docs/internal/ai-offline-evaluation-reporting.md
+++ b/docs/internal/ai-offline-evaluation-reporting.md
@@ -248,6 +248,9 @@ Numeric scores can use the domain of their configured scorer version. The API va
`OfflineEvalSuite.experiment_options` accepts the SDK's `ExperimentOptions` for external dataset identifiers, `prompt_version`, model/application versions, and `run_source`.
The harness owns the experiment ID, start time, suite key, and expected counts.
+Each case's `output["model"]` becomes the item's `model_version`, so one-shot suites can report the model they actually used and compare different models within one experiment.
+Cases without a model, or with `model=None`, inherit the experiment's model version.
+Invalid model identifiers are omitted and recorded in the item's `omitted_fields`; the case's scores still upload.
For external datasets, case metadata can supply `dataset_item_identifier` and `dataset_item_version_identifier`.
The harness publisher does not support hosted datasets. A non-null `dataset_revision_id` stops preparation with a local error before any upload request.
diff --git a/docs/internal/conversations-reliability.md b/docs/internal/conversations-reliability.md
index 8bb2da092d6d..9f6e92260441 100644
--- a/docs/internal/conversations-reliability.md
+++ b/docs/internal/conversations-reliability.md
@@ -115,6 +115,7 @@ That is provider acceptance, not customer delivery.
Timeouts after a possible accept retry with the same `client_msg_id`.
Honor `Retry-After` on 429s, cap at one hour, and treat 5xx plus Slack timeout codes as transient.
Permanent Slack application errors (revoked token, missing channel, invalid blocks) fail the part.
+Automated PR replies check the approved channel list and current channel type before posting. A failed channel lookup retries; a known external channel fails the part.
Keep `post_reply_to_slack` registered for in-flight Celery messages.
New work uses `process_slack_delivery_part`.
diff --git a/docs/internal/workflows-email-sending-tiers.md b/docs/internal/workflows-email-sending-tiers.md
index 6914badeaa51..73e358d91950 100644
--- a/docs/internal/workflows-email-sending-tiers.md
+++ b/docs/internal/workflows-email-sending-tiers.md
@@ -12,6 +12,7 @@ Only workflows that send email are subject to the tiers; SMS, push, and webhook
- Batch audience cap: `get_hogflow_batch_trigger_limit` in `products/workflows/backend/utils/batch_trigger_limit.py`, applied at batch dispatch and shown in the blast radius preview.
- Send-time caps: two Valkey token buckets per team in the CDP email worker (`claimTeamSendingBudget` in `nodejs/src/cdp/services/messaging/email.service.ts`).
- Staff controls: the team's Django admin page (view state, set/pin a tier, recompute now).
+- Audit trail: every tier or pin change writes an `email_sending_tier_changed` row to the team's activity log, with the before and after values and the decision reason (`staff_set` for a tier staff set by hand). Staff rows name the acting user. Sweep and backfill rows carry a trigger with the job type and a per-run id. The rows are staff-only in the activity log (`products/workflows/backend/services/email_sending_tier_audit.py`).
- Customer surface: the Reputation tab's sending allowance card, shown only while the team is enforced.
## Configuration
diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml
index e5bdaafd7701..fe15a410a10a 100644
--- a/frontend/snapshots.yml
+++ b/frontend/snapshots.yml
@@ -3968,6 +3968,10 @@ snapshots:
hash: v1.k794b7964.77db5fd39abf5b41db372ff16af2044f3c03b303aabe0d592f6c25de1898339a.3VJBqA15CtN76iDMdubH40FmnriyLRFCW_mGRXdYrsM
insights-funnellinechart--with-legend--light:
hash: v1.k794b7964.d3d505f78dc4785055c98a602b0edd9c39549001dab1a3f1c3bf753f1cff60be.Lu6z04Zq2foZ5axwQK8Hg4ys33BYX2OIMjQkpYBycKM
+ insights-funnelstepsbarchart--alert-thresholds--dark:
+ hash: v1.k794b7964.f07a01625198eb67f2bf3709a2b562abf1e5c9fb6e0ad50d32bc3c0455810e4c.kcHPaKTNWm1MsjfK7ZXC-YOSHARImNlxhU63hmrI9zk
+ insights-funnelstepsbarchart--alert-thresholds--light:
+ hash: v1.k794b7964.1615420c253f38e627b2d08757e17d1bd748369b520353077fb7cb69b60ce73d.fckv-4nelywj6ArxmnSQa8Xt5h6ZySRMvXTpCuoTF-A
insights-funnelstepsbarchart--bar-labels--dark:
hash: v1.k794b7964.5de2ee4fdd7ba021e1af1363b1e7977b10a245ce3ae6486008b83552cc41296d.tMGnyCUxbncHrGz42VrPrOAdXqsv6oqpZP2N6hjMW48
insights-funnelstepsbarchart--bar-labels--light:
@@ -9029,57 +9033,57 @@ snapshots:
scenes-app-ai-observability-evaluations-onboarding--no-online-evaluations--light:
hash: v1.k794b7964.1dbea4bbbd7b0731b841cfdf0dc28826e9bba9caf9735cc7257e0fc4e0a67026.ezM9M-EOjWsTZXJk602I44gvDXvOzobiHJq09E0OEac
scenes-app-ai-observability-evaluations-overview--directory-open--dark:
- hash: v1.k794b7964.aa11f061ae0ad10fe341206b1223e0a608df0286d2c5d283b54696d4e21a3f97.jl6VVZGQDVDqUkvv3N-uFqifk4b-MykizHrmyhgG_zc
+ hash: v1.k794b7964.9e8467472787599992a607e83fc725f1808762fc1c33cd0ec4f097e457e1411c.VdW-6vKl9WLQcs3_PcFk76quSN32MHTKKi65Mr2AtIw
scenes-app-ai-observability-evaluations-overview--directory-open--light:
- hash: v1.k794b7964.76820f10ccf488b27e0905c7bad9dd2891737b4a4e5fa7cd88121d163cd67080.WVgilucVDuLKEEUvDcyzOPGkFFvzXe40TmaEyVoJJng
+ hash: v1.k794b7964.cf730b6a5ea171f8188571f7517979529e8d5ec0921f8dd540f7566f1c82c668.7lLao0Lap4qcDipaQoM5qHydVIUqSOF10kmighTbX7I
scenes-app-ai-observability-evaluations-overview--drawer-categories--dark:
- hash: v1.k794b7964.833c9ec088f5ced0b6349de96401cea531c80532b1ccab3e0ede6280075fd177.g5BobVDmlWFirkzQXWggOgdaGlZTyzgXsFSsl3wXnNU
+ hash: v1.k794b7964.1396006b1c513ffac8152d3328c2c4dae1d2f6d089568d83368ed601cbd4c4fc.TO_VIpBW3waQiAPRFmArwyxs1ypiEdjQzEdBU1Y9Dys
scenes-app-ai-observability-evaluations-overview--drawer-categories--light:
- hash: v1.k794b7964.bc7f0d4f7a4eaef7ecf8f8b62cfb879294d2844e856de264ba499a995d638bcb._YBpi1OPAd5HQqHxeTkTxBuCr3090LVXixCm3cMTVGQ
+ hash: v1.k794b7964.ac765a38024efc26131f1e94b0de8ae7de76a272b3f8e3ed876ef65114b0d654.vEvC2Oe0TrrHptfcYHPLuicyHRutHV85C0mGtFyRQxE
scenes-app-ai-observability-evaluations-overview--drawer-empty-disabled--dark:
- hash: v1.k794b7964.7de3916414d975768f14847157679565558e99c7ac9b75d849ffdf754be78401.VUL4gYDHiOk8Ii6VYTrKxtlsyLBwwPzkaNjLNFKWcdA
+ hash: v1.k794b7964.7469928bacc2c1ea5c0ee877309f2474bec0daadce99c0c642339a13c36bc198.2_TdbaZqvXx1BhEqm8Z4VTEW0EDJruEcgzqCVQ_a3wg
scenes-app-ai-observability-evaluations-overview--drawer-empty-disabled--light:
- hash: v1.k794b7964.cbf3fc3daeed93e20270ba78e5c2928f8a71db99e19e38be16ca760c07fcd43f.Rbe8TyAOiBinF4WY1zffPcSFXPv5PaSnXMyTTxsRgTA
+ hash: v1.k794b7964.c9ef8678f104bd65444d83faa8e770403fb3b19a481ef3ee7c9feeee352e39d5.B4Y9VJuw8kjkQzJqOMW4CH5pYdUdCFyk-AAfj6oL10E
scenes-app-ai-observability-evaluations-overview--drawer-empty-enabled--dark:
- hash: v1.k794b7964.aa7184c72e72454e93eb3913486bdc8c295a5a2a8aabda3cf566af7d5917d08f.R_OIyIubYv5EgQU__8ZV-O0wNqEp3qQBSvBg30AUtwc
+ hash: v1.k794b7964.5e3466575fd4688b526dceddb7025fbd3ab51c595c42a2971438e0f8fced3d1b.zNvjVBwMJH4sfnBIUwqCJeCRLBWR2tQXGf7yT3JHb5E
scenes-app-ai-observability-evaluations-overview--drawer-empty-enabled--light:
- hash: v1.k794b7964.c5d3c557c2451f148ecc57ec3cec957f1b7f9f886712ebb174466fd9e4c4bb40.HOpxeq9U5LXpb1QbF3LUC9AYyk5netaRIatrGIUkk2w
+ hash: v1.k794b7964.d3e6bbc0d6083da0b972859131952b9459157957dbc9280dde510212ca2d4638.in6HA2hHmpo_O8hdFffEjCZSVYywO1RJfXEe7BZ8JWQ
scenes-app-ai-observability-evaluations-overview--drawer-empty-errored--dark:
- hash: v1.k794b7964.3af08d5f476b419f3e8063e8e49e0a7c9ec7e659c0b7a5680e4a37e862d280a4.irbSSkw1Sl-MHvdClZkXAzFms2Hx7vvNASxfQIcoNJk
+ hash: v1.k794b7964.d80c0068d1d69550abcc359f47100102e5b5280dc33c5debc791cba160faddf3.gx2US6d2uRQVNqkfidFZ-aUhEs0Ya52y7gELEC2mWj4
scenes-app-ai-observability-evaluations-overview--drawer-empty-errored--light:
- hash: v1.k794b7964.a0a3b37ec30366fad6ddc1ee8b2341aaef91dd14860e3015805ce50e16c2d687.vsCMotdO-txIgRF6ZOPV1WCd09ltBeMXSyHyxjSaIz8
+ hash: v1.k794b7964.183ed459b8ac0cc01560f9982ba22b237517dc84d32ba13c8f499ce5a544d2b5.jpWsfKXNRY_3PhIN8t_ibgpnbR-Fsj8De0KO0hg9b1s
scenes-app-ai-observability-evaluations-overview--drawer-pass-rate--dark:
- hash: v1.k794b7964.338a65fdf80c494f4c0bb14cbb394493accab1ead6d57f4334419ff70e312166.LsHX79e2LcxsjZepYXH0dROrSlc28Y214VvdTum_zkI
+ hash: v1.k794b7964.1a36eabcc772ca25d6a0a7f0bf88e7d09aca70af6451ad6bc7630874442fd56b.RRG8R-7Ppi9PX2mdyLSdCLwghhimaoXXVs0KX6CfX1s
scenes-app-ai-observability-evaluations-overview--drawer-pass-rate--light:
- hash: v1.k794b7964.8629ca843e0589c5dd4b1457baea907511013f39a2ed42fa97a678d493710d9f.1e6I8pl3FgbYIPgEqzYpmZkW96r7y6N5ii0uKo0sf7g
+ hash: v1.k794b7964.5754eeaf8f25e6df5632b09532a9420fad33f75de11aa8f432e68925d2533c2e.5kHPXR7lvVsAdhuPVUCVKemutoMKdigqWb4JJ29DwP8
scenes-app-ai-observability-evaluations-overview--drawer-score--dark:
- hash: v1.k794b7964.053969fd2ca12903073c6e72757051c7ba95a1f7f7975f837e8d485339fc47fb.HBvwpYIW0D7hayu-EIhuw_x1Zwgksif80XFrt7MNExA
+ hash: v1.k794b7964.344bfa1890ae9973250672a9f29f937c1c6f1c0ce597ba1d23e440c5275d6cfa.D64Cbn94PGRWxYS-0Tub1jMZJDGPtF-udeJHbD8nRgo
scenes-app-ai-observability-evaluations-overview--drawer-score--light:
- hash: v1.k794b7964.1054c1259b718c56fc952efb7c05566d8e6a6b8276fdccb55039ea2f10c55183.X7d7tz6c6mo07nN9hKgFnB4HvCEmQ8-2v2PpZ1McI4E
+ hash: v1.k794b7964.b51db15a42f22bf705ea9afe54098493d704643921237e9d143aecef70e61f32.8iE8eoI8IPCQCYzqrOKwuCaQMp6w8M-z_Ol5JZVOJno
scenes-app-ai-observability-evaluations-overview--drawer-sentiment--dark:
- hash: v1.k794b7964.e5172bf8b3bc693e71512452904fcf3cd26431ac11312a63c42b85181bebe4b1.wjR_IxYD_1S6XDEz3mgEajeCYSje3TnIPS7hCh0D_ts
+ hash: v1.k794b7964.d8341fd34ae2cbe558d59567c78318fe2d4aaf329b037bcd2ac6f3b76c18afb4.LcaNUJ-_d6LXhVS-Lv7_eb_1J8beFP6b2x8jSbuQeN0
scenes-app-ai-observability-evaluations-overview--drawer-sentiment--light:
- hash: v1.k794b7964.1dcd8c708d2896cf51abdefb8e953869986df3c3c329e204d860d3cf4296c0e7.lELtcnQIt5ZqwXeqMXNm7Rd-iexljTfywGkRz0jT96o
+ hash: v1.k794b7964.22b7faf544f624a6b94e72fbc6153c6d0329e5c1b14b2eb1f9e243e20c24d9df.uf1oNJwlqjesk2OB_EhiiAPypny8rcq3_y-BnNAsBP0
scenes-app-ai-observability-evaluations-overview--many-evaluations--dark:
- hash: v1.k794b7964.217c7fb285b2538e867684455d6a6f47168acc21beb2526dbb7d6ce3676e5801.97AUffXw8nnPAz3QLYb4WWMqVJdCf48Vsd-o2zEp6Xw
+ hash: v1.k794b7964.b47ee72ea5ce1258149bd327c2b4d2dfc0173f255bfcdb868acbb0829d45c06b.oXeU3j9LVWCGRv7SUKvxOAPFJoXrwSQYO0gfezg8dn8
scenes-app-ai-observability-evaluations-overview--many-evaluations--light:
- hash: v1.k794b7964.c11bc3b421ac8269e555f971075f4ca1733baf5ab884b1ea4837d44556c17412.-lOl68J121_CwMPgqZLa8fO2gS_Q4apOSlbehBeG25Q
+ hash: v1.k794b7964.31d902373f25edf7afc844ca5ac040ea1e643b8ff8b3230e52bc1251b06f2cc0.Gyqkp0iS5fBKUNpJGjUCJLiNZa7OB6Hjolgo8vPt-Ck
scenes-app-ai-observability-evaluations-overview--many-evaluations-with-directories--dark:
- hash: v1.k794b7964.4664f59130c034f4385171bbb5d0ce20cbdbd3f71ecc7cc86065a80a2a146215.MUFt92N2_BcrkvLJUoTFd9AgppmyQWdgqAUgi51PA1s
+ hash: v1.k794b7964.2103291df89ea5ac4e2b2c2c0b602a9237578227e53d3ccee0df8ebdf4462469.Fo4CWjXvtaKRzKkIBcOnkSGqPxT3BoMpSgoyXjRJk7w
scenes-app-ai-observability-evaluations-overview--many-evaluations-with-directories--light:
- hash: v1.k794b7964.f910e9853b427b4df365b2d628bec4c7a46c0eca175dc47bf289bc3ae3f70f07.GH2v1WUUh8zqbr8ZXQ05FDa_6R5poKG5WyGAAzf0raI
+ hash: v1.k794b7964.f2ac9295978b7f8172971aec9342f79c3570aae2e8dff864bcf45b1fb9ab7aef.g0R6KkYGyzzkbCalShqPtUyrYCaSgp3rtaVtMBNXagI
scenes-app-ai-observability-evaluations-overview--nothing-graded--dark:
- hash: v1.k794b7964.cb84e0d16d170ab75fac059bfdaedc4b777ae1af0ca83ebf24e2513b0fa1377e.-JZEVRiCP5z3Wem9gj8rwvs9DohtKICs51RT5d7PcEQ
+ hash: v1.k794b7964.4d1290a38f30ecb1bc13e47492a6684327af210ca28bf2d5de893e2d79545701.d-szMo-h5AvMziJCww7cbMBHk2j_Evr-jdnFP236oNk
scenes-app-ai-observability-evaluations-overview--nothing-graded--light:
- hash: v1.k794b7964.52f8eacef3006e94ff4447758d2aa81fea1ca24cf995552e04e6fd2c6f18194b.S3E4zPzJfMuCWjiZq67Im7dAQxr2g5qOdKQ8I3YBoKE
+ hash: v1.k794b7964.6bd929184a75dc6c7a052cd6fc3a81b94bda1df59003335bb31d35b1c658f48c.92qVT3rL6DGlJdCm0dD2qXudg_DuLh87pZSVsJYrWK8
scenes-app-ai-observability-evaluations-overview--rate-limited--dark:
- hash: v1.k794b7964.e8aa37a66dc03651a6633b7e997672b8abf80f42a8efe8df7f9a685af6321f4a.iraGLjZfIqSCcP-vxVIYB22Fe31a7f4ScQi6rcGnB3g
+ hash: v1.k794b7964.0a716fd857838f8c6d836fb271ba27bc8b03807f9d14392528f487fcdb85f2b3.9Y-HU-8rkjlq8f0YS7rS69uE3-ckqDr27mH0TukD6mo
scenes-app-ai-observability-evaluations-overview--rate-limited--light:
- hash: v1.k794b7964.db4495726f9c49b37dbe0fc0d388c4de8cc2a807f265554d2d40b22a361a59b5.kcY7HlDaxjApOhHIujs93_5fTT2Gt0VFQYl3PxORgYs
+ hash: v1.k794b7964.48bfc1d4c5823c8a5494cf5bf79ef910d1c06d77315f5b28810570b3b26a2109.NnZNfIuP_J5KMBwtFMEqT1boYiKsI4StZzd62NQzys0
scenes-app-ai-observability-evaluations-overview--two-evaluations--dark:
- hash: v1.k794b7964.0610effc7cf8f2c225b1299e5ae680fa6c74f64500eda628afdea1f43107f3f2.DQCrsNJFXpbSKtX_VaYxP5TeYZfRr9TRSsXzCPR9WYg
+ hash: v1.k794b7964.24177f3a0ab02c226de812af2a7236ca39efa20130a5e3f90b7eb8b9ef8b9fda.4fFcQQuS2W_Ge3tRrUxO5y8iC3h0XcVIp3VQw02oAjE
scenes-app-ai-observability-evaluations-overview--two-evaluations--light:
- hash: v1.k794b7964.a76865401cf83c825694b9470b4b992e89fc5ef03d2c2b0b3eceef8537b3b6bc.fyh9_jc67w9X9PzZjtH4_SEgKE4THhWmmRtfjjWDgnE
+ hash: v1.k794b7964.84206a664057f52d1b347eb3b25f0753aa6bc6511e115a84f6d8e81f2db20c88.KHYA6WgaFepaL76lvAalXU2Nzg4xhH60_2gIsul7LAM
scenes-app-ai-observability-image-preview--failed-image--dark:
hash: v1.k794b7964.e4f4b0993222ae8f8a3f0e59e4c0528312f13a1e358df37e53d4331b4dacbae5.oDWA2N3BGPuGJvd9voAvMpM2rCR7tfrLjEwDqJT6MYk
scenes-app-ai-observability-image-preview--failed-image--light:
@@ -11252,6 +11256,14 @@ snapshots:
hash: v1.k794b7964.4b4f9908a2552fc97dd30fe179ca38caeb074f454f46ade87b3ca90ef0cd7f70.0GRyu-AtfFknvRJUEg03KJaNlwPBv9-SzOo4hXWpgoY
scenes-app-experiments--experiment-results-row-recording-links-unselectable-metric--light:
hash: v1.k794b7964.08f0ff958ed2d355bc6a0c9f5048f35e7f75b2cd34bacb867dd56f4ae5e4bf93.BNe46IwANTcBPvWEWBrpf78YOLf5cgWoIF1K-cni1J0
+ scenes-app-experiments--experiment-running-time-on-track--dark:
+ hash: v1.k794b7964.afa5d2bc13065d7380ed28208c4e2eabdb1f4c45121ed0208c403ea955b674e6.MO0qXYuaERQK6SXloMRlNHcmGocr0DyiesdR3ZayFtk
+ scenes-app-experiments--experiment-running-time-on-track--light:
+ hash: v1.k794b7964.36487dd075f2235217d9b4fad812b91e9d51f2b22c9500120c81bb0bcc3548c7.0IhzfZMJNdt1pAKXr5f3bvcK9hFC2IvE_EfSlNUcwgQ
+ scenes-app-experiments--experiment-running-time-out-of-reach--dark:
+ hash: v1.k794b7964.76ee3239ae3823c6749b82d2d0f39d9e345f86aec33c8553847b59867ca1993d.RHLG1HcCDNxMpGjZ18XoRArDCNhQC2xDXjeGKXC5aNI
+ scenes-app-experiments--experiment-running-time-out-of-reach--light:
+ hash: v1.k794b7964.9dc2d830c6fc93df43da8d7ca01861af94d85aacaa7ab6ba202ce9d89ff46236.XVTEPecb7FA5Q0n3544d00ftbnOvv5tGWMxl0oR9VeE
scenes-app-experiments--experiment-stopped-with-conclusion--dark:
hash: v1.k794b7964.578ffd24470c381648d034ab3f9454d9f448430ae15d09e34b4e61b6441c3258.aOoEZotWjnQ2NmVYfdoTIz3AF8y1Y8DTCrF9YeavSQk
scenes-app-experiments--experiment-stopped-with-conclusion--light:
@@ -12049,13 +12061,13 @@ snapshots:
scenes-app-inbox-first-report--no-data-narrow--light:
hash: v1.k794b7964.8a760fde0be568a914e4453aac2c128a6ec065c0a6c01ef91eb016cbf11cf5ae.f8H0eCk6dHfGuY7f9eWdK5mjitwkzbGGrjg2ANeiNMM
scenes-app-inbox-first-report--running--dark:
- hash: v1.k794b7964.2bb4016dfbab0d8c8f1378706ab67bb51e84955b08685645596828789bb856d9.TZHjE1omnp-7XaQgd50zWuWvXUPdKlr-5ycTO7gJGJE
+ hash: v1.k794b7964.8873c63918500c67c125bf1810cfcbe0ebca8cb6326c7474a6d32760ce3108fc.cdTBqbm_zunougoPEEbKrq5YoNTbEhvWOoEZpP4w9cw
scenes-app-inbox-first-report--running--light:
- hash: v1.k794b7964.4d6369a69c7adb8a3edeadc4acc9cacc45bb2cbe753a68b1e8d336fbf9404053.JH83D4JcoC0XR8h7FgHmPxWiV5CAP6ZUqPzVkBYM5qU
+ hash: v1.k794b7964.6cad8925cb5f4e92a40d869f7bd5fec9080d4b0d810c6911f7a7935ddb912ebb.p7cBdOTLZxGPaazscXGseUZdlDMYNEOT3B-NFZCbxAQ
scenes-app-inbox-first-report--running-narrow--dark:
- hash: v1.k794b7964.1489893faeb3232bcd9ae74915614d155359ce14793fdcc57962a757d646e078.p5QygOekc_jO7LrBCAySkt7BCZpUi8TMtoCeVntqh90
+ hash: v1.k794b7964.83e568a89db9806e44fa63bb21de51c0611974fd86f5e78818c83d13cd981962.uHejFGwT0d13MiZd9dYf3wh0zj-GRRsKNKBUbj4YkRE
scenes-app-inbox-first-report--running-narrow--light:
- hash: v1.k794b7964.d994a9d5e1c127959958aa3e468fce4945cea47afe00e61fd1d7df37ec27c4b9.Isj5PvJ7ELE3a--ZB3q2pv1l6D3-vjIV0-J7X8pcR94
+ hash: v1.k794b7964.6247637182b919d676f1f745366b1f66417a72e61e3d69326931e3d71e763284.5vftvvyKgzlcrWlVtUR4EgW4S4aI_7ShVqifNBqYJH8
scenes-app-inbox-focused-setup--built-in-scouts--dark:
hash: v1.k794b7964.9c362d9b9c26bb883910cfbdd1836a4527c166f4c96a2f5360e69023c79d5f0e.DuYijIxQuSoZCwSuCLHfE5vpQ9S7-wEi7vbYSPvUoQY
scenes-app-inbox-focused-setup--built-in-scouts--light:
@@ -13605,9 +13617,9 @@ snapshots:
scenes-app-marketing-analytics-search-performance--placement-unavailable--light:
hash: v1.k794b7964.9132ab7a37c8519e4fb52a37d6623c8eea2969d69532342ce877bd4c31fcc184.glpephRinlnPE2nB831WYYOKL8P3gKw_4_yDgVAA24E
scenes-app-marketing-analytics-search-performance--position-metrics--dark:
- hash: v1.k794b7964.adf8c4f8927a8a0e131e3548b4d1e75798e478b8cdc7232b388f9381780902cc.2uhi2pewKTyDwAHs2W5Iuv7eZLYCQGCThyAJ9CBLE7U
+ hash: v1.k794b7964.95bd2000c35ea1689e482196cf423e67a26aa274afb3007b2bd43a595f0baec2.sOzOlU1b1PPJZkEfOZ_sj8Tf_Fi4r-CWrIFPPPnNmsA
scenes-app-marketing-analytics-search-performance--position-metrics--light:
- hash: v1.k794b7964.d5099dea2f3faf5bbb5bd4dc76bbb0224762efbfed459dc9a99c3bf19023bd67.Iz50mTZ2ak6wLLJAw0_h2QWLczqy8xwEhqjf0SrR0ng
+ hash: v1.k794b7964.9a55e5c29016c60fc359238d47f15def8b79e5f69b9102eca41a934ab4e014d2.iIdIdmdc2rFnn02foj7OY36r_qsFVNt7f8OaX74zjA8
scenes-app-marketing-analytics-search-performance--post-hog-conversions--dark:
hash: v1.k794b7964.9d3e260b6c10d6ef412dd784d10973a8649a9c67995ff07c1dc4ce42b29bd886.8SXJebwFKiup-BKVjtiRUkx5D8kJiWjYjhtaaIoy5_g
scenes-app-marketing-analytics-search-performance--post-hog-conversions--light:
@@ -15781,9 +15793,9 @@ snapshots:
scenes-app-tasks--pi-task-selected--light:
hash: v1.k794b7964.dc94140b8460e76c6e8b81adb9c46997c89a5634f20fac20ddada1f1ffd12223.4KOsU0tDj1zB26GFLV3daFR9ypOtXtETf08ZsGEMZt4
scenes-app-tasks--task-detail-loading--dark:
- hash: v1.k794b7964.cf68a26a8d95ea04f3a13aa312f1ef15587ae7c533366fe679299a19f0e37dcc.6TZiaw-L4PN6eRjj342Xu9Zg9hSM2oqOFcM4l7GhyTs
+ hash: v1.k794b7964.a34fe05f1131d0368d2d803f27320c93f7a9bd5bcebc31d2459be0ed16e059c5.uSNsO42bTdJ1tYa-Bmz4uTocYRX1SIbo2vaa9IKq1AM
scenes-app-tasks--task-detail-loading--light:
- hash: v1.k794b7964.c8fb0a7ee9be9b7e43c86e1ef0ab08afa36fc1ee73e37c7db3f295cacd7f428e.LTBA5SJdNLyUSgSShns_HEJxKkHVYFN0T0lGWEKMTPs
+ hash: v1.k794b7964.f896d7c2b7cff888483338a1fededcca9f993a0b1dc1ec1bfcca6820b654c641.BHwh8AdR4NFJJrLw-CM4YYGlfVSb9XksqPOw2DeyKvg
scenes-app-tasks--task-load-error--dark:
hash: v1.k794b7964.8c160753039593f051efc147d69f447a861f14a8281d97f8f612b1b2279bb241.wB8WL7fVnS3sNEHJtbST0gUGVoyv2V2TwcnemxoSeBw
scenes-app-tasks--task-load-error--light:
diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts
index f721cac84c30..cc2cd50ecf3e 100644
--- a/frontend/src/generated/core/api.schemas.ts
+++ b/frontend/src/generated/core/api.schemas.ts
@@ -7051,6 +7051,19 @@ export type FileSystemDestroyParams = {
recursive?: boolean
}
+export type FileSystemFrequentListParams = {
+ /**
+ * Maximum number of frequently viewed items to return.
+ * @minimum 1
+ * @maximum 20
+ */
+ limit?: number
+ /**
+ * A search term.
+ */
+ search?: string
+}
+
export type FileSystemSearchRetrieveParams = {
/**
* Maximum number of results to return, best first.
diff --git a/frontend/src/generated/core/api.ts b/frontend/src/generated/core/api.ts
index f10262f8a804..8ce22cc15922 100644
--- a/frontend/src/generated/core/api.ts
+++ b/frontend/src/generated/core/api.ts
@@ -36,6 +36,7 @@ import type {
ExportsListParams,
FileSystemApi,
FileSystemDestroyParams,
+ FileSystemFrequentListParams,
FileSystemHomeFolderApi,
FileSystemListParams,
FileSystemRankedSearchResponseApi,
@@ -1946,6 +1947,36 @@ export const fileSystemCountByPathCreate = async (
})
}
+export const getFileSystemFrequentListUrl = (projectId: string, params?: FileSystemFrequentListParams) => {
+ const normalizedParams = new URLSearchParams()
+
+ Object.entries(params || {}).forEach(([key, value]) => {
+ if (value !== undefined) {
+ normalizedParams.append(key, value === null ? 'null' : String(value))
+ }
+ })
+
+ const stringifiedParams = normalizedParams.toString()
+
+ return stringifiedParams.length > 0
+ ? `/api/projects/${projectId}/file_system/frequent/?${stringifiedParams}`
+ : `/api/projects/${projectId}/file_system/frequent/`
+}
+
+/**
+ * @summary List frequently viewed files
+ */
+export const fileSystemFrequentList = async (
+ projectId: string,
+ params?: FileSystemFrequentListParams,
+ options?: RequestInit
+): Promise => {
+ return apiMutator(getFileSystemFrequentListUrl(projectId, params), {
+ ...options,
+ method: 'GET',
+ })
+}
+
export const getFileSystemHomeFolderCreateUrl = (projectId: string) => {
return `/api/projects/${projectId}/file_system/home_folder/`
}
diff --git a/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx b/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx
index 25a7a16d2c25..bffb44378702 100644
--- a/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx
+++ b/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx
@@ -168,6 +168,48 @@ describe('humanizeActivity', () => {
}
})
+ it.each<{ reason: string; changes: Partial[]; action: string; description: string }>([
+ {
+ reason: 'staff_set',
+ changes: [
+ { field: 'email_sending_tier', before: 2, after: 3 },
+ { field: 'email_sending_tier_pinned', before: false, after: true },
+ ],
+ action: 'Set the workflow email sending tier from 2 to 3 and pinned it there',
+ description: 'A user set the workflow email sending tier on Example from 2 to 3 and pinned it there',
+ },
+ {
+ reason: 'clean_and_used',
+ changes: [{ field: 'email_sending_tier', before: 1, after: 2 }],
+ action: 'Moved the workflow email sending tier from 1 to 2',
+ description: 'A user moved the workflow email sending tier on Example from 1 to 2 (reason: clean_and_used)',
+ },
+ {
+ reason: 'staff_set',
+ changes: [{ field: 'email_sending_tier_pinned', before: true, after: false }],
+ action: 'Unpinned the workflow email sending tier',
+ description: 'A user unpinned the workflow email sending tier on Example',
+ },
+ ])('describes an email sending tier change: $action', ({ reason, changes, action, description }) => {
+ const logItem = makeLogItem({
+ scope: ActivityScope.TEAM,
+ activity: 'email_sending_tier_changed',
+ detail: {
+ merge: null,
+ trigger: null,
+ name: 'Example',
+ context: { reason },
+ changes: changes.map((change) => ({ type: ActivityScope.TEAM, action: 'changed', ...change })),
+ },
+ })
+ const result = teamActivityDescriber(logItem)
+ const text = (value: Description | undefined): string =>
+ (render(<>{value}>).container.textContent || '').replace(/\s+/g, ' ').trim()
+
+ expect(text(result.description)).toBe(description)
+ expect(text(result.summary?.action)).toBe(action)
+ })
+
it.each([
['minted', 'A user created Developer mode token for a@posthog.com'],
['revoked', 'A user revoked Developer mode token for a@posthog.com'],
diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
index 6327a771dbc0..1c421dcaf2d4 100644
--- a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
+++ b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
@@ -31,6 +31,7 @@ import { accessLevelSatisfied } from 'lib/utils/accessControlUtils'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { capitalizeFirstLetter } from 'lib/utils/strings'
+import { reportDashboardInsightMetaUpdated } from 'scenes/dashboard/dashboardEvents'
import { getEffectiveDateOverride } from 'scenes/dashboard/dashboardUtils'
import { dataRetentionBannerLogic } from 'scenes/insights/dataRetention/dataRetentionBannerLogic'
import { exceedsRetention } from 'scenes/insights/dataRetention/exceedsRetention'
@@ -216,7 +217,7 @@ export function InsightMeta({
const { copyImage } = useActions(captureImageLogic)
const { isCapturing: isCapturingImage } = useValues(captureImageLogic)
const { updateInsightDirect } = useActions(insightsModel)
- const { reportDashboardInsightMetaUpdated, reportInsightResultsCopiedToClipboard } = useActions(eventUsageLogic)
+ const { reportInsightResultsCopiedToClipboard } = useActions(eventUsageLogic)
const { featureFlags } = useValues(featureFlagLogic)
const showCompactTile =
diff --git a/frontend/src/lib/components/MarkdownNotebook/EditableTableBlock.tsx b/frontend/src/lib/components/MarkdownNotebook/EditableTableBlock.tsx
index ec7ed81152b1..9860211b4c08 100644
--- a/frontend/src/lib/components/MarkdownNotebook/EditableTableBlock.tsx
+++ b/frontend/src/lib/components/MarkdownNotebook/EditableTableBlock.tsx
@@ -12,7 +12,7 @@ import {
useState,
} from 'react'
-import { IconMinus, IconPlus } from '@posthog/icons'
+import { IconMinus, IconPlus, IconTrash } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'
import { shouldUseMarkdownPaste } from './documentModel'
@@ -111,6 +111,7 @@ export function EditableTableBlock({
setBlockRef,
setTableCellRef,
updateNode,
+ deleteNode,
handleSelectionChange,
startTextSelectionPointer,
restoreSelectionRef,
@@ -120,6 +121,7 @@ export function EditableTableBlock({
setBlockRef: (element: HTMLElement | null) => void
setTableCellRef: (position: TableCellPosition, element: HTMLElement | null) => void
updateNode: (nodeId: string, updater: (node: NotebookBlockNode) => NotebookBlockNode | null) => void
+ deleteNode: () => void
handleSelectionChange: () => void
startTextSelectionPointer: (event: TextSelectionPointerStartEvent) => void
restoreSelectionRef: MutableRefObject
@@ -481,6 +483,7 @@ export function EditableTableBlock({
label={control.label}
tooltip={control.tooltip}
icon={ }
+ dataAttr="markdown-notebook-table-add-row"
onClick={() => addTableRowAfter(control.rowIndex, 0)}
/>
@@ -499,6 +502,7 @@ export function EditableTableBlock({
label={control.label}
tooltip={control.tooltip}
icon={ }
+ dataAttr="markdown-notebook-table-add-column"
onClick={() => addTableColumnAfter(control.columnIndex)}
/>
@@ -519,6 +523,7 @@ export function EditableTableBlock({
label={`Remove row ${rowIndex + 1}`}
tooltip="Remove row"
icon={ }
+ dataAttr="markdown-notebook-table-remove-row"
onClick={() => removeTableRow(rowIndex)}
/>
@@ -540,6 +545,7 @@ export function EditableTableBlock({
label={`Remove column ${columnIndex + 1}`}
tooltip="Remove column"
icon={ }
+ dataAttr="markdown-notebook-table-remove-column"
disabledReason={
columnCount <= 1 ? 'Tables need at least one column' : undefined
}
@@ -548,6 +554,21 @@ export function EditableTableBlock({
)
})}
+
+
}
+ dataAttr="markdown-notebook-table-delete"
+ onClick={deleteNode}
+ />
+
) : null}
@@ -560,12 +581,14 @@ export function TableStructureControlButton({
label,
tooltip,
icon,
+ dataAttr,
disabledReason,
onClick,
}: {
label: string
tooltip: string
icon: JSX.Element
+ dataAttr: string
disabledReason?: string
onClick: () => void
}): JSX.Element {
@@ -573,6 +596,7 @@ export function TableStructureControlButton({
{
+ const onChange = jest.fn()
+ const { container } = render(
+ createElement(MarkdownNotebook, {
+ value: withNotebookTitle(`Intro paragraph\n\n${tableMarkdown}`),
+ onChange,
+ })
+ )
+ const lastCell = Array.from(
+ container.querySelectorAll('.MarkdownNotebook__table-cell-content')
+ ).at(-1) as HTMLElement
+
+ placeCaretInElement(lastCell)
+ fireEvent.keyDown(lastCell, { key: 'Backspace' })
+
+ expect(container.querySelector('.MarkdownNotebook__table-block') === null).toEqual(!!expectedMarkdown)
+ expect(onChange.mock.calls.at(-1)?.[0]).toEqual(expectedMarkdown)
+ })
+
+ it('deletes a table with content from its delete control', () => {
+ const onChange = jest.fn()
+ const { container } = render(
+ createElement(MarkdownNotebook, {
+ value: withNotebookTitle('Intro paragraph\n\n| A | B |\n| --- | --- |\n| 1 | 2 |'),
+ onChange,
+ })
+ )
+
+ fireEvent.click(container.querySelector('button[aria-label="Delete table"]') as HTMLButtonElement)
+
+ expect(container.querySelector('.MarkdownNotebook__table-block')).toBeNull()
+ expect(onChange).toHaveBeenLastCalledWith(`${TEST_NOTEBOOK_TITLE_MARKDOWN}\n\nIntro paragraph`)
+ })
+
it('keeps newly inserted components active for keyboard row actions', () => {
const onChange = jest.fn()
const { container } = render(createElement(MarkdownNotebook, { value: withNotebookTitle(' '), onChange }))
diff --git a/frontend/src/lib/components/MarkdownNotebook/MarkdownNotebook.tsx b/frontend/src/lib/components/MarkdownNotebook/MarkdownNotebook.tsx
index 78fef0d2897f..972c93cab307 100644
--- a/frontend/src/lib/components/MarkdownNotebook/MarkdownNotebook.tsx
+++ b/frontend/src/lib/components/MarkdownNotebook/MarkdownNotebook.tsx
@@ -234,6 +234,7 @@ import {
getTableCellRefKey,
getTableColumnCount,
getTableEdgeCellPosition,
+ isTableEmpty,
makeEmptyTableRow,
normalizeTableRow,
tableCellPositionsEqual,
@@ -4736,6 +4737,27 @@ function MarkdownNotebookEditor({
return true
}, [commitDocument, requestFocusAfterRemovingNode])
+ const deleteEmptyTableAtCurrentSelection = useCallback((): boolean => {
+ const element = getSelectedInlineEditableElementOfType(
+ notebookRef.current,
+ 'MarkdownNotebook__table-cell-content'
+ )
+ const nodeId = element?.dataset.markdownNotebookNodeId
+ if (!element || !nodeId) {
+ return false
+ }
+
+ const nodes = documentRef.current.nodes.length ? documentRef.current.nodes : [emptyNodeRef.current]
+ const node = nodes.find((currentNode) => currentNode.id === nodeId)
+ if (!node || node.type !== 'table' || !isTableEmpty(node)) {
+ return false
+ }
+
+ requestFocusAfterRemovingNode(nodeId)
+ deleteNodeWithRefCleanup(nodeId)
+ return true
+ }, [deleteNodeWithRefCleanup, requestFocusAfterRemovingNode])
+
const insertParagraphBelowTrailingCodeBlockAtCurrentSelection = useCallback((): boolean => {
const element = getSelectedInlineEditableElementOfType(notebookRef.current, 'MarkdownNotebook__code-block')
const nodeId = element?.dataset.markdownNotebookNodeId
@@ -6774,7 +6796,8 @@ function MarkdownNotebookEditor({
!event.ctrlKey &&
(deleteListItemRangeAtCurrentSelection() ||
deleteListItemAtCurrentSelection(event.key === 'Backspace' ? 'backward' : 'forward') ||
- deleteEmptyCodeBlockAtCurrentSelection())
+ deleteEmptyCodeBlockAtCurrentSelection() ||
+ deleteEmptyTableAtCurrentSelection())
) {
event.preventDefault()
event.stopPropagation()
diff --git a/frontend/src/lib/components/MarkdownNotebook/renderNode.tsx b/frontend/src/lib/components/MarkdownNotebook/renderNode.tsx
index 346a783b0ed5..55f9c2c6e063 100644
--- a/frontend/src/lib/components/MarkdownNotebook/renderNode.tsx
+++ b/frontend/src/lib/components/MarkdownNotebook/renderNode.tsx
@@ -235,6 +235,7 @@ export function renderNode({
setBlockRef={setBlockRef}
setTableCellRef={setTableCellRef}
updateNode={updateNode}
+ deleteNode={deleteNodeAndFocusAdjacent}
handleSelectionChange={handleSelectionChange}
startTextSelectionPointer={startTextSelectionPointer}
restoreSelectionRef={restoreSelectionRef}
diff --git a/frontend/src/lib/components/MarkdownNotebook/tableModel.ts b/frontend/src/lib/components/MarkdownNotebook/tableModel.ts
index 0546d539e819..754b6efdd653 100644
--- a/frontend/src/lib/components/MarkdownNotebook/tableModel.ts
+++ b/frontend/src/lib/components/MarkdownNotebook/tableModel.ts
@@ -1,5 +1,6 @@
import { InsertMenuSelectionDirection, TableCellPosition } from './editorTypes'
import { NotebookTableBlockNode, NotebookTableCell } from './types'
+import { getInlineText } from './utils'
export function getTableCellRefKey(nodeId: string, position: TableCellPosition): string {
return `${nodeId}:${position.section}:${String(position.rowIndex)}:${String(position.columnIndex)}`
@@ -17,6 +18,10 @@ export function makeEmptyTableRow(columnCount: number): NotebookTableCell[] {
return Array.from({ length: columnCount }, () => ({ children: [] }))
}
+export function isTableEmpty(node: NotebookTableBlockNode): boolean {
+ return [node.headers, ...node.rows].every((row) => row.every((cell) => !getInlineText(cell.children).length))
+}
+
export function getTableCellPositions(node: NotebookTableBlockNode): TableCellPosition[] {
const columnCount = getTableColumnCount(node)
return [
diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index d7faf9a6af30..2f9f7c3ab33f 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -271,7 +271,6 @@ export const FEATURE_FLAGS = {
BILLING_ALERTS: 'billing-alerts', // owner: #team-billing, gates the Billing > Alerts tab
BILLING_REAL_TIME_USAGE: 'billing-real-time-usage', // owner: #team-billing, gates the Real-time usage scene
BROADCASTS_AI_FIRST_NEW: 'broadcasts-ai-first-new', // owner: @dmarchuk #team-workflows
- BUSINESS_KNOWLEDGE_GITHUB_REPOS: 'business-knowledge-github-repos', // owner: @veryayskiy #team-conversations
CANVAS_COMMENTS: 'posthog-desktop-canvas-comments', // owner: @k11kirky, shows canvas comments and selection comments on web canvases, shared with PostHog Desktop
CDP_DWH_TABLE_SOURCE: 'cdp-dwh-table-source', // owner: #team-workflows-cdp
CDP_DWH_VIEW_SOURCE: 'cdp-dwh-view-source', // owner: #team-workflows-cdp
@@ -545,6 +544,7 @@ export const FEATURE_FLAGS = {
TAXONOMIC_FILTER_SEARCH_INTENT: 'taxonomic-filter-search-intent', // owner: @pauldambra multivariate=control,banner,promote, classifies a picker search with the decision model. control changes nothing, banner suggests the right tab, promote moves the predicted group to the top of the All tab
TAXONOMIC_FILTER_VALUE_SEARCH_GATING: 'taxonomic-filter-value-search-gating', // owner: @pauldambra
TEXT_CARD_WORD_ART: 'text-card-word-art', // owner: @jonmcwest, gates the word art insert button in dashboard text cards
+ TODAY_JUMP_BACK_RANKING: 'today-jump-back-ranking',
TODAY_PULSE_RANKING: 'today-pulse-ranking',
TODAY_RAIL_NAV: 'today-rail-nav', // owner: @k11kirky, replaces the left navigation with a rail (Home, Spaces, Library, Tools) and makes the Today page the homepage
TODAY_REPORT_JEV: 'today-report-jev', // owner: @puemos, asks the decision model to label the problem, cause and fix in a Today report, to mark the numbers a source states, and to pick the code excerpt a finding describes
diff --git a/frontend/src/lib/utils/eventUsageLogic.test.ts b/frontend/src/lib/utils/eventUsageLogic.test.ts
index ce6a599603d0..833309477af0 100644
--- a/frontend/src/lib/utils/eventUsageLogic.test.ts
+++ b/frontend/src/lib/utils/eventUsageLogic.test.ts
@@ -14,21 +14,13 @@ import {
BaseMathType,
BehavioralEventType,
ChartDisplayType,
- type DashboardTile,
- type DashboardType,
FilterLogicalOperator,
FunnelVizType,
PropertyFilterType,
StepOrderValue,
} from '~/types'
-import {
- dashboardViewedProperties,
- eventUsageLogic,
- getEventPropertiesForMetric,
- sanitizeDashboard,
- sanitizeQuery,
-} from './eventUsageLogic'
+import { eventUsageLogic, getEventPropertiesForMetric, sanitizeQuery } from './eventUsageLogic'
describe('eventUsageLogic', () => {
describe('experiments list viewed', () => {
@@ -377,108 +369,6 @@ describe('eventUsageLogic', () => {
})
})
- it('keeps metric variable definitions out of a sanitized dashboard', () => {
- const metricVariables = [{ name: 'env', source: 'sql', query: 'SELECT private_value' }]
- const tile = { id: 1, insight: { id: 2, filter_override_context: { dashboard: { metricVariables } } } }
- const dashboard = { id: 7, tiles: [tile], filters: { metricVariables }, persisted_filters: { metricVariables } }
-
- const sanitized = sanitizeDashboard(dashboard as unknown as DashboardType)
-
- expect(JSON.stringify(sanitized)).not.toContain('private_value')
- })
-
- describe('dashboardViewedProperties', () => {
- const dashboard = (tiles: Partial[]): DashboardType =>
- ({
- id: 7,
- created_at: '2026-01-01T00:00:00Z',
- is_shared: false,
- pinned: true,
- creation_mode: 'default',
- created_by: { uuid: 'creator' },
- tiles,
- }) as unknown as DashboardType
-
- const insightTile = (query: unknown, is_sample = false): Partial =>
- ({ insight: { query, is_sample } }) as unknown as Partial
-
- const cases: [string, Partial[], Record][] = [
- ['no tiles', [], { item_count: 0, sample_items_count: 0 }],
- [
- 'an insight tile with a query',
- [insightTile({ kind: NodeKind.InsightVizNode, source: { kind: NodeKind.TrendsQuery, series: [] } })],
- { item_count: 1, text_count: 1, uses_data_warehouse_source: false, data_warehouse_tiles_count: 0 },
- ],
- ['an insight tile without a query', [insightTile(null)], { item_count: 1, empty_count: 1 }],
- [
- 'a sample insight tile that reads the warehouse',
- [insightTile({ kind: NodeKind.TrendsQuery, series: [{ kind: NodeKind.DataWarehouseNode }] }, true)],
- {
- item_count: 1,
- text_count: 1,
- sample_items_count: 1,
- uses_data_warehouse_source: true,
- data_warehouse_tiles_count: 1,
- },
- ],
- [
- 'text and widget tiles',
- [{ text: { body: 'hi' } }, { text: { body: 'there' } }, { widget: {} }] as Partial[],
- { item_count: 3, text_tiles_count: 2, widget_tiles_count: 1 },
- ],
- [
- 'groups once however many tiles share a key',
- [
- { group_key: 'a' },
- { group_key: 'a' },
- { group_key: 'b' },
- { group_key: null },
- ] as Partial[],
- { item_count: 4, text_tiles_count: 4, group_count: 2 },
- ],
- ]
-
- it.each(cases)('counts %s', (_, tiles, expected) => {
- expect(dashboardViewedProperties(dashboard(tiles), null, 'viewer')).toEqual({
- created_at: '2026-01-01T00:00:00Z',
- is_shared: false,
- pinned: true,
- creation_mode: 'default',
- viewer_is_creator: false,
- created_by_system: false,
- dashboard_id: 7,
- group_display: 'sections',
- group_count: 0,
- lastRefreshed: undefined,
- refreshAge: undefined,
- sample_items_count: 0,
- uses_data_warehouse_source: false,
- data_warehouse_tiles_count: 0,
- ...expected,
- })
- })
-
- it('reports the dashboard group display', () => {
- const withTabs = { ...dashboard([]), customization: { group_display: 'tabs' } } as unknown as DashboardType
- expect(dashboardViewedProperties(withTabs, null, 'viewer')).toMatchObject({ group_display: 'tabs' })
- })
-
- const viewerCases: [string, { uuid: string } | null, string | undefined, boolean | undefined][] = [
- ['the creator views it', { uuid: 'creator' }, 'creator', true],
- ['another user views it', { uuid: 'creator' }, 'viewer', false],
- ['the viewer is unknown', { uuid: 'creator' }, undefined, undefined],
- ['the system created it', null, 'viewer', undefined],
- ]
-
- it.each(viewerCases)('reports viewer_is_creator when %s', (_, created_by, viewerUuid, expected) => {
- const withCreator = { ...dashboard([]), created_by } as unknown as DashboardType
- expect(dashboardViewedProperties(withCreator, null, viewerUuid)).toMatchObject({
- viewer_is_creator: expected,
- created_by_system: created_by === null,
- })
- })
- })
-
it('reports only the kind for a legacy metric', () => {
const metric = {
kind: NodeKind.ExperimentFunnelsQuery,
diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts
index 819c36c661de..787a9367d7be 100644
--- a/frontend/src/lib/utils/eventUsageLogic.ts
+++ b/frontend/src/lib/utils/eventUsageLogic.ts
@@ -2,8 +2,6 @@ import { MakeLogicType, actions, connect, kea, listeners, path } from 'kea'
import posthog from 'posthog-js'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
-import type { Dayjs } from 'lib/dayjs'
-import { now } from 'lib/dayjs'
import type { IntegrationConnectSurface, IntegrationLinkExistingCounts } from 'lib/integrations/utils'
import { TimeToSeeDataPayload } from 'lib/internalMetrics'
import { preflightLogic } from 'lib/logic/preflightLogic'
@@ -56,10 +54,6 @@ import {
import {
ChartDisplayType,
CohortType,
- DashboardTileSpacing,
- DashboardMode,
- DashboardTile,
- DashboardType,
Experiment,
ExperimentHoldoutType,
ExperimentIdType,
@@ -68,7 +62,6 @@ import {
PropertyFilterType,
InsightModel,
type SDK,
- TileGroupDisplay,
} from '~/types'
import { captureBIWorksheetAction } from 'products/business_intelligence/frontend/biEditorAnalytics'
@@ -98,16 +91,6 @@ export enum DashboardEventSource {
DashboardVariableOverride = 'dashboard_variable_override',
}
-export type DashboardFilterChangeType =
- | 'date'
- | 'properties'
- | 'breakdown'
- | 'variable'
- | 'interval'
- | 'test_accounts'
- | 'compare'
- | 'metric_variables'
-
export enum InsightEventSource {
LongPress = 'long_press',
MoreDropdown = 'more_dropdown',
@@ -608,34 +591,6 @@ export function sanitizeInsight(insight: Partial | null): object |
return sanitizedInsight
}
-function sanitizeTile(tile: DashboardTile | null): object | undefined {
- if (!tile) {
- return undefined
- }
-
- return {
- ...tile,
- insight: tile.insight ? sanitizeInsight(tile.insight) : undefined,
- }
-}
-
-export function sanitizeDashboard(dashboard: DashboardType | null): object | null {
- if (!dashboard) {
- return null
- }
-
- // Remove breakdown color entries, as they carry raw breakdown values (user data) — report only their count
- const { breakdown_colors, ...sanitizedDashboard } = dashboard
-
- return {
- ...sanitizedDashboard,
- breakdown_colors_count: breakdown_colors?.length ?? 0,
- filters: filtersForAnalytics(dashboard.filters ?? {}),
- persisted_filters: dashboard.persisted_filters ? filtersForAnalytics(dashboard.persisted_filters) : null,
- tiles: dashboard.tiles?.map((tile) => sanitizeTile(tile)) || [],
- }
-}
-
function countBehavioralFilters(value: unknown): number {
if (Array.isArray(value)) {
return value.reduce((count, item) => count + countBehavioralFilters(item), 0)
@@ -712,69 +667,6 @@ function funnelProperties(querySource: InsightQueryNode): SanitizedQuery {
}
}
-function increment(counts: Record, key: string): void {
- counts[key] = (counts[key] || 0) + 1
-}
-
-function insightTileCountKey(tile: DashboardTile): string {
- const query = isNodeWithSource(tile.insight?.query) ? tile.insight.query.source : tile.insight?.query
- return `${query?.kind || !!tile.text ? 'text' : 'empty'}_count`
-}
-
-function countDashboardTiles(tiles: DashboardTile[], properties: Record): void {
- for (const tile of tiles) {
- if (tile.insight) {
- increment(properties, insightTileCountKey(tile))
- properties.sample_items_count += tile.insight.is_sample ? 1 : 0
- if (queryUsesDataWarehouse(tile.insight.query)) {
- properties.uses_data_warehouse_source = true
- properties.data_warehouse_tiles_count += 1
- }
- } else if (tile.widget) {
- increment(properties, 'widget_tiles_count')
- } else {
- increment(properties, 'text_tiles_count')
- }
- }
-}
-
-export function tileGroupProperties(
- groupDisplay: TileGroupDisplay | undefined,
- tiles: DashboardTile[]
-): { group_display: TileGroupDisplay; group_count: number } {
- return {
- group_display: groupDisplay ?? 'sections',
- group_count: new Set(tiles.map((tile) => tile.group_key).filter(Boolean)).size,
- }
-}
-
-export function dashboardViewedProperties(
- dashboard: DashboardType,
- lastRefreshed: Dayjs | null,
- viewerUuid: string | undefined
-): Record {
- const { created_at, is_shared, pinned, creation_mode, id } = dashboard
- const properties: Record = {
- created_at,
- is_shared,
- pinned,
- creation_mode,
- viewer_is_creator:
- dashboard.created_by?.uuid && viewerUuid ? dashboard.created_by.uuid === viewerUuid : undefined,
- sample_items_count: 0,
- item_count: dashboard.tiles?.length || 0,
- created_by_system: !dashboard.created_by,
- dashboard_id: id,
- ...tileGroupProperties(dashboard.customization?.group_display, dashboard.tiles || []),
- lastRefreshed: lastRefreshed?.toISOString(),
- refreshAge: lastRefreshed ? now().diff(lastRefreshed, 'seconds') : undefined,
- uses_data_warehouse_source: false,
- data_warehouse_tiles_count: 0,
- }
- countDashboardTiles(dashboard.tiles || [], properties)
- return properties
-}
-
/** Takes a query and returns an object with "useful" properties that don't contain sensitive data. */
export function sanitizeQuery(query: Node | null): SanitizedQuery {
const payload: SanitizedQuery = {
@@ -938,95 +830,6 @@ export interface eventUsageLogicActions {
journeyName: string
stepCount: number
}
- reportDashboardAddMenuOpened: (
- source: 'header',
- dashboardId: number
- ) => {
- dashboardId: number
- source: 'header'
- }
- reportDashboardEditModeDiscardPrompt: (
- dashboard: DashboardType | null,
- action: 'discarded' | 'kept_editing' | 'shown'
- ) => {
- action: 'discarded' | 'kept_editing' | 'shown'
- dashboard: DashboardType | null
- }
- reportDashboardEmptyAddChartClicked: (dashboardId: number | undefined) => {
- dashboardId: number | undefined
- }
- reportDashboardEmptyAiPromptSubmitted: (
- dashboardId: number | undefined,
- promptType: 'custom_prompt' | 'starter_question'
- ) => {
- dashboardId: number | undefined
- promptType: 'custom_prompt' | 'starter_question'
- }
- reportDashboardEmptyWebAnalyticsClicked: (dashboardId: number | undefined) => {
- dashboardId: number | undefined
- }
- reportDashboardFiltersChanged: (
- dashboard: DashboardType | null,
- changeType: DashboardFilterChangeType,
- properties: Record
- ) => {
- changeType: DashboardFilterChangeType
- dashboard: DashboardType | null
- properties: Record
- }
- reportDashboardInsightDeleteAfterRemovalClicked: (otherDashboardCount: number) => {
- otherDashboardCount: number
- }
- reportDashboardInsightMetaUpdated: (
- dashboardId: number | undefined,
- insightId: number,
- attribute: 'description' | 'name'
- ) => {
- attribute: 'description' | 'name'
- dashboardId: number | undefined
- insightId: number
- }
- reportDashboardLayoutEditModeEntered: (
- dashboard: DashboardType | null,
- source: DashboardEventSource,
- layoutZoom: number | null
- ) => {
- dashboard: DashboardType | null
- layoutZoom: number | null
- source: DashboardEventSource
- }
- reportDashboardModeToggled: (
- dashboard: DashboardType | null,
- mode: DashboardMode | null,
- source: DashboardEventSource | null,
- layoutZoom: number | null,
- layoutEditMode?: boolean
- ) => {
- dashboard: DashboardType | null
- layoutEditMode: boolean | undefined
- layoutZoom: number | null
- mode: DashboardMode | null
- source: DashboardEventSource | null
- }
- reportDashboardMoveInitiated: (
- method: 'bulk' | 'single',
- count: number
- ) => {
- count: number
- method: 'bulk' | 'single'
- }
- reportDashboardTileDensityConfigured: (tileDensity: DashboardTileSpacing) => {
- tileDensity: DashboardTileSpacing
- }
- reportDashboardTileRepositioned: (
- dashboardId: number,
- action: 'moved' | 'resized',
- layoutZoom: number
- ) => {
- action: 'moved' | 'resized'
- dashboardId: number
- layoutZoom: number
- }
reportDataManagementDefinitionClickEdit: (type: TaxonomicFilterGroupType) => {
type: TaxonomicFilterGroupType
}
@@ -1729,47 +1532,6 @@ export const eventUsageLogic = kea([
reportProjectCreationSubmitted: (projectCount: number, nameLength: number) => ({ projectCount, nameLength }),
reportProjectNoticeDismissed: (key: string) => ({ key }),
reportProjectNoticeShown: (variant: string) => ({ variant }),
- reportDashboardModeToggled: (
- dashboard: DashboardType | null,
- mode: DashboardMode | null,
- source: DashboardEventSource | null,
- layoutZoom: number | null,
- layoutEditMode?: boolean
- ) => ({ dashboard, mode, source, layoutZoom, layoutEditMode }),
- reportDashboardLayoutEditModeEntered: (
- dashboard: DashboardType | null,
- source: DashboardEventSource,
- layoutZoom: number | null
- ) => ({ dashboard, source, layoutZoom }),
- reportDashboardFiltersChanged: (
- dashboard: DashboardType | null,
- changeType: DashboardFilterChangeType,
- properties: Record
- ) => ({ dashboard, changeType, properties }),
- reportDashboardTileDensityConfigured: (tileDensity: DashboardTileSpacing) => ({ tileDensity }),
- reportDashboardEditModeDiscardPrompt: (
- dashboard: DashboardType | null,
- action: 'shown' | 'discarded' | 'kept_editing'
- ) => ({ dashboard, action }),
- reportDashboardMoveInitiated: (method: 'single' | 'bulk', count: number) => ({ method, count }),
- reportDashboardInsightDeleteAfterRemovalClicked: (otherDashboardCount: number) => ({ otherDashboardCount }),
- reportDashboardTileRepositioned: (dashboardId: number, action: 'moved' | 'resized', layoutZoom: number) => ({
- dashboardId,
- action,
- layoutZoom,
- }),
- reportDashboardInsightMetaUpdated: (
- dashboardId: number | undefined,
- insightId: number,
- attribute: 'name' | 'description'
- ) => ({ dashboardId, insightId, attribute }),
- /** Empty-state AI prompt chips (ai-first empty dashboard only). */
- reportDashboardEmptyAiPromptSubmitted: (
- dashboardId: number | undefined,
- promptType: 'custom_prompt' | 'starter_question'
- ) => ({ dashboardId, promptType }),
- reportDashboardEmptyAddChartClicked: (dashboardId: number | undefined) => ({ dashboardId }),
- reportDashboardEmptyWebAnalyticsClicked: (dashboardId: number | undefined) => ({ dashboardId }),
reportTimezoneComponentViewed: (
component: 'label' | 'indicator',
project_timezone?: string,
@@ -1782,7 +1544,6 @@ export const eventUsageLogic = kea([
reportSessionTableVersionUpdated: (version: string) => ({ version }),
reportCustomChannelTypeRulesUpdated: (numRules: number) => ({ numRules }),
reportCreatedDashboardFromModal: true,
- reportDashboardAddMenuOpened: (source: 'header', dashboardId: number) => ({ source, dashboardId }),
/** Dashboard created via PostHog web app from a template (new dashboard modal / template chooser). */
reportSavedInsightToDashboard: (insight: Partial | null, dashboardId: number | null) => ({
insight,
@@ -2220,82 +1981,6 @@ export const eventUsageLogic = kea([
reportDataTableColumnsUpdated: async ({ context_type }) => {
posthog.capture('data table columns updated', { context_type })
},
- reportDashboardModeToggled: async ({ dashboard, mode, source, layoutZoom, layoutEditMode }) => {
- posthog.capture('dashboard mode toggled', {
- dashboard_id: dashboard?.id,
- dashboard: sanitizeDashboard(dashboard),
- mode,
- source,
- layout_zoom: layoutZoom ?? undefined,
- layout_edit_mode: layoutEditMode ?? undefined,
- })
- },
- reportDashboardLayoutEditModeEntered: async ({ dashboard, source, layoutZoom }) => {
- posthog.capture('dashboard layout edit mode entered', {
- dashboard_id: dashboard?.id,
- dashboard: sanitizeDashboard(dashboard),
- source,
- layout_zoom: layoutZoom ?? undefined,
- })
- },
- reportDashboardFiltersChanged: async ({ dashboard, changeType, properties }) => {
- posthog.capture('dashboard filters changed', {
- dashboard_id: dashboard?.id,
- change_type: changeType,
- ...properties,
- })
- },
- reportDashboardTileDensityConfigured: async ({ tileDensity }) => {
- posthog.capture('dashboard tile density configured', { tile_density: tileDensity })
- },
- reportDashboardEditModeDiscardPrompt: async ({ dashboard, action }) => {
- posthog.capture('dashboard edit mode discard prompt', {
- dashboard_id: dashboard?.id,
- dashboard: sanitizeDashboard(dashboard),
- action,
- })
- },
- reportDashboardMoveInitiated: async ({ method, count }) => {
- posthog.capture('dashboard move initiated', { method, count })
- },
- reportDashboardTileRepositioned: async ({ dashboardId, action, layoutZoom }) => {
- posthog.capture('dashboard tile repositioned', {
- dashboard_id: dashboardId,
- action,
- layout_zoom: layoutZoom,
- })
- },
- reportDashboardInsightMetaUpdated: async ({ dashboardId, insightId, attribute }) => {
- posthog.capture('dashboard insight meta updated', {
- dashboard_id: dashboardId,
- insight_id: insightId,
- attribute,
- })
- },
- reportDashboardInsightDeleteAfterRemovalClicked: async ({ otherDashboardCount }) => {
- posthog.capture('dashboard insight delete after removal clicked', {
- other_dashboard_count: otherDashboardCount,
- })
- },
- reportDashboardEmptyAiPromptSubmitted: async ({ dashboardId, promptType }) => {
- posthog.capture('dashboard empty ai prompt submitted', {
- dashboard_id: dashboardId,
- prompt_type: promptType,
- source: 'web',
- })
- },
- reportDashboardEmptyAddChartClicked: async ({ dashboardId }) => {
- posthog.capture('dashboard empty add chart clicked', {
- dashboard_id: dashboardId,
- source: 'web',
- })
- },
- reportDashboardEmptyWebAnalyticsClicked: async ({ dashboardId }) => {
- posthog.capture('dashboard empty web analytics clicked', {
- dashboard_id: dashboardId,
- source: 'web',
- })
- },
reportTimezoneComponentViewed: async (payload) => {
posthog.capture('timezone component viewed', payload)
},
@@ -2329,9 +2014,6 @@ export const eventUsageLogic = kea([
reportCreatedDashboardFromModal: async () => {
posthog.capture('created new dashboard from modal')
},
- reportDashboardAddMenuOpened: async ({ source, dashboardId }) => {
- posthog.capture('dashboard add menu opened', { source, dashboard_id: dashboardId })
- },
reportSavedInsightToDashboard: async ({ insight, dashboardId }) => {
if (isBIVisualizationNode(insight?.query)) {
captureBIWorksheetAction('added_to_dashboard', insight.query.config, { insight_id: insight.id })
diff --git a/frontend/src/scenes/dashboard/DashboardAiPromptComposer.tsx b/frontend/src/scenes/dashboard/DashboardAiPromptComposer.tsx
index 6b6518bda821..f93ab53b64ea 100644
--- a/frontend/src/scenes/dashboard/DashboardAiPromptComposer.tsx
+++ b/frontend/src/scenes/dashboard/DashboardAiPromptComposer.tsx
@@ -5,9 +5,9 @@ import type { FormEvent } from 'react'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonTextArea } from 'lib/lemon-ui/LemonTextArea/LemonTextArea'
import { Link } from 'lib/lemon-ui/Link'
-import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { dashboardAiPromptComposerLogic } from './dashboardAiPromptComposerLogic'
+import { reportDashboardEmptyAiPromptSubmitted } from './dashboardEvents'
function reportDashboardEmptyAiPromptClicked(
promptLabel: string,
@@ -127,7 +127,6 @@ export function DashboardAiPromptComposer({
}: DashboardAiPromptComposerProps): JSX.Element {
const { prompt, promptSource } = useValues(dashboardAiPromptComposerLogic)
const { setPrompt } = useActions(dashboardAiPromptComposerLogic)
- const { reportDashboardEmptyAiPromptSubmitted } = useActions(eventUsageLogic)
const submitPrompt = (event: FormEvent): void => {
event.preventDefault()
diff --git a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx
index 82deb57f72b6..b21e889df087 100644
--- a/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx
+++ b/frontend/src/scenes/dashboard/DashboardHeaderActions.tsx
@@ -10,7 +10,7 @@ import { LemonBadge } from 'lib/lemon-ui/LemonBadge'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonMenu, LemonMenuItem, LemonMenuItems } from 'lib/lemon-ui/LemonMenu'
import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils'
-import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic'
+import { DashboardEventSource } from 'lib/utils/eventUsageLogic'
import { MaxTool } from 'scenes/max/MaxTool'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
@@ -22,6 +22,7 @@ import { DashboardCustomizeMenu } from 'products/dashboards/frontend/components/
import { DashboardAddTileHandlers, DashboardAddTileKey, getDashboardAddTileOptions } from './dashboardAddTileOptions'
import { DashboardCustomizeButton } from './DashboardCustomizeButton'
+import { reportDashboardAddMenuOpened } from './dashboardEvents'
import { DashboardLoadAction, dashboardLogic } from './dashboardLogic'
import { DashboardSubscribeButton } from './DashboardSubscribeButton'
@@ -61,7 +62,6 @@ export function DashboardAddTileButton(): JSX.Element | null {
openImageTileModal,
openButtonTileModal,
} = useActions(dashboardLogic)
- const { reportDashboardAddMenuOpened } = useActions(eventUsageLogic)
if (!dashboard || tiles.length === 0) {
return null
diff --git a/frontend/src/scenes/dashboard/DashboardItems.test.tsx b/frontend/src/scenes/dashboard/DashboardItems.test.tsx
index 1382f994dac8..f95ac1ed668e 100644
--- a/frontend/src/scenes/dashboard/DashboardItems.test.tsx
+++ b/frontend/src/scenes/dashboard/DashboardItems.test.tsx
@@ -5,7 +5,6 @@ import { useActions, useAsyncActions, useValues } from 'kea'
import { router } from 'kea-router'
import type { PositionStrategy } from 'react-grid-layout/core'
-import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { dashboardsModel } from '~/models/dashboardsModel'
@@ -39,7 +38,6 @@ jest.mock('~/models/insightsModel', () => ({
}))
jest.mock('lib/utils/eventUsageLogic', () => ({
- eventUsageLogic: { __mock: 'eventUsageLogic' },
DashboardEventSource: {
CardEdgeHover: 'CardEdgeHover',
CardDragHandle: 'CardDragHandle',
@@ -303,12 +301,6 @@ describe('DashboardItems', () => {
}
}
- if (logic === eventUsageLogic) {
- return {
- reportDashboardTileRepositioned: jest.fn(),
- }
- }
-
if (logic === router) {
return {
push: jest.fn(),
diff --git a/frontend/src/scenes/dashboard/DashboardItems.tsx b/frontend/src/scenes/dashboard/DashboardItems.tsx
index 5f5ae9f0f805..87178912ee08 100644
--- a/frontend/src/scenes/dashboard/DashboardItems.tsx
+++ b/frontend/src/scenes/dashboard/DashboardItems.tsx
@@ -15,8 +15,9 @@ import { ApiError } from 'lib/api'
import { EditModeEdge, useResizeHandleScrollbarPassThrough } from 'lib/components/Cards/InsightCard/EditModeEdgeOverlay'
import { TileDecorations } from 'lib/components/TileDecorations/TileDecorations'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
-import { DashboardEventSource, eventUsageLogic } from 'lib/utils/eventUsageLogic'
+import { DashboardEventSource } from 'lib/utils/eventUsageLogic'
import { objectsEqual } from 'lib/utils/objects'
+import { reportDashboardTileRepositioned } from 'scenes/dashboard/dashboardEvents'
import { DashboardLoadAction, dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import {
BREAKPOINTS,
@@ -146,7 +147,6 @@ export function DashboardItems({ showCreateAnomalyAlertButton }: DashboardItemsP
moveTileToSection,
} = useActions(dashboardLogic)
const { updateWidgetTile } = useAsyncActions(dashboardLogic)
- const { reportDashboardTileRepositioned } = useActions(eventUsageLogic)
const { push } = useActions(router)
const { data: surveyLinkedInsights, loading: surveyLinkedInsightsLoading } = useSurveyLinkedInsights({
// Dashboard pages hide the survey suggestion in compact headers; notebook embeds also use this component and can show it.
diff --git a/frontend/src/scenes/dashboard/DashboardZoomControl.tsx b/frontend/src/scenes/dashboard/DashboardZoomControl.tsx
index 5edd66d5fb80..32793310373f 100644
--- a/frontend/src/scenes/dashboard/DashboardZoomControl.tsx
+++ b/frontend/src/scenes/dashboard/DashboardZoomControl.tsx
@@ -4,9 +4,9 @@ import posthog from 'posthog-js'
import { LemonButton } from '@posthog/lemon-ui'
import { Shortcut } from 'lib/components/Shortcuts/Shortcut'
-import { sanitizeDashboard } from 'lib/utils/eventUsageLogic'
import { Scene } from 'scenes/sceneTypes'
+import { sanitizeDashboard } from './dashboardEvents'
import { dashboardLogic } from './dashboardLogic'
interface DashboardZoomControlProps {
diff --git a/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx b/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx
index 97100cec8b65..560acf4e7c7e 100644
--- a/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx
+++ b/frontend/src/scenes/dashboard/EmptyDashboardComponent.tsx
@@ -9,7 +9,6 @@ import { AccessControlAction } from 'lib/components/AccessControlAction'
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'
-import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { maxGlobalLogic } from 'scenes/max/maxGlobalLogic'
import { urls } from 'scenes/urls'
@@ -18,6 +17,7 @@ import { AccessControlLevel, AccessControlResourceType, DashboardType, SidePanel
import { addInsightToDashboardLogic } from './addInsightToDashboardModalLogic'
import { DashboardAiPromptComposer } from './DashboardAiPromptComposer'
+import { reportDashboardEmptyAddChartClicked, reportDashboardEmptyWebAnalyticsClicked } from './dashboardEvents'
import { DASHBOARD_CANNOT_EDIT_MESSAGE } from './DashboardHeader'
import { getAddTileMenuItems } from './DashboardHeaderActions'
import { dashboardLogic } from './dashboardLogic'
@@ -47,7 +47,6 @@ function DashboardEmptyActions({
onAddWidget: () => void
onOpenAiWithPrompt: (prompt: string) => void
}): JSX.Element {
- const { reportDashboardEmptyAddChartClicked, reportDashboardEmptyWebAnalyticsClicked } = useActions(eventUsageLogic)
const chipDisabledReason = !canEdit ? DASHBOARD_CANNOT_EDIT_MESSAGE : aiDisabledReason || undefined
const handleAddInsight = (): void => {
reportDashboardEmptyAddChartClicked(dashboard?.id)
diff --git a/frontend/src/scenes/dashboard/QuillDashboardAddTileButton.tsx b/frontend/src/scenes/dashboard/QuillDashboardAddTileButton.tsx
index 41741cb50086..283034ca718f 100644
--- a/frontend/src/scenes/dashboard/QuillDashboardAddTileButton.tsx
+++ b/frontend/src/scenes/dashboard/QuillDashboardAddTileButton.tsx
@@ -13,13 +13,13 @@ import {
} from '@posthog/quill'
import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils'
-import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { MaxTool } from 'scenes/max/MaxTool'
import { iconForType } from '~/layout/panel-layout/ProjectTree/defaultTree'
import { AccessControlLevel, AccessControlResourceType } from '~/types'
import { DashboardAddTileKey, getDashboardAddTileOptions } from './dashboardAddTileOptions'
+import { reportDashboardAddMenuOpened } from './dashboardEvents'
import { DashboardLoadAction, dashboardLogic } from './dashboardLogic'
// pinned: autocapture data-attrs for the header's Add menu items
@@ -41,7 +41,6 @@ export function QuillDashboardAddTileButton(): JSX.Element | null {
openImageTileModal,
openButtonTileModal,
} = useActions(dashboardLogic)
- const { reportDashboardAddMenuOpened } = useActions(eventUsageLogic)
if (!dashboard || tiles.length === 0) {
return null
diff --git a/frontend/src/scenes/dashboard/dashboardEvents.test.ts b/frontend/src/scenes/dashboard/dashboardEvents.test.ts
new file mode 100644
index 000000000000..cf67e2dcb8be
--- /dev/null
+++ b/frontend/src/scenes/dashboard/dashboardEvents.test.ts
@@ -0,0 +1,108 @@
+import { NodeKind } from '~/queries/schema/schema-general'
+import type { DashboardTile, DashboardType } from '~/types'
+
+import { dashboardViewedProperties, sanitizeDashboard } from './dashboardEvents'
+
+describe('dashboardEvents', () => {
+ it('keeps metric variable definitions out of a sanitized dashboard', () => {
+ const metricVariables = [{ name: 'env', source: 'sql', query: 'SELECT private_value' }]
+ const tile = { id: 1, insight: { id: 2, filter_override_context: { dashboard: { metricVariables } } } }
+ const dashboard = { id: 7, tiles: [tile], filters: { metricVariables }, persisted_filters: { metricVariables } }
+
+ const sanitized = sanitizeDashboard(dashboard as unknown as DashboardType)
+
+ expect(JSON.stringify(sanitized)).not.toContain('private_value')
+ })
+
+ describe('dashboardViewedProperties', () => {
+ const dashboard = (tiles: Partial[]): DashboardType =>
+ ({
+ id: 7,
+ created_at: '2026-01-01T00:00:00Z',
+ is_shared: false,
+ pinned: true,
+ creation_mode: 'default',
+ created_by: { uuid: 'creator' },
+ tiles,
+ }) as unknown as DashboardType
+
+ const insightTile = (query: unknown, is_sample = false): Partial =>
+ ({ insight: { query, is_sample } }) as unknown as Partial
+
+ const cases: [string, Partial[], Record][] = [
+ ['no tiles', [], { item_count: 0, sample_items_count: 0 }],
+ [
+ 'an insight tile with a query',
+ [insightTile({ kind: NodeKind.InsightVizNode, source: { kind: NodeKind.TrendsQuery, series: [] } })],
+ { item_count: 1, text_count: 1, uses_data_warehouse_source: false, data_warehouse_tiles_count: 0 },
+ ],
+ ['an insight tile without a query', [insightTile(null)], { item_count: 1, empty_count: 1 }],
+ [
+ 'a sample insight tile that reads the warehouse',
+ [insightTile({ kind: NodeKind.TrendsQuery, series: [{ kind: NodeKind.DataWarehouseNode }] }, true)],
+ {
+ item_count: 1,
+ text_count: 1,
+ sample_items_count: 1,
+ uses_data_warehouse_source: true,
+ data_warehouse_tiles_count: 1,
+ },
+ ],
+ [
+ 'text and widget tiles',
+ [{ text: { body: 'hi' } }, { text: { body: 'there' } }, { widget: {} }] as Partial[],
+ { item_count: 3, text_tiles_count: 2, widget_tiles_count: 1 },
+ ],
+ [
+ 'groups once however many tiles share a key',
+ [
+ { group_key: 'a' },
+ { group_key: 'a' },
+ { group_key: 'b' },
+ { group_key: null },
+ ] as Partial[],
+ { item_count: 4, text_tiles_count: 4, group_count: 2 },
+ ],
+ ]
+
+ it.each(cases)('counts %s', (_, tiles, expected) => {
+ expect(dashboardViewedProperties(dashboard(tiles), null, 'viewer')).toEqual({
+ created_at: '2026-01-01T00:00:00Z',
+ is_shared: false,
+ pinned: true,
+ creation_mode: 'default',
+ viewer_is_creator: false,
+ created_by_system: false,
+ dashboard_id: 7,
+ group_display: 'sections',
+ group_count: 0,
+ lastRefreshed: undefined,
+ refreshAge: undefined,
+ sample_items_count: 0,
+ uses_data_warehouse_source: false,
+ data_warehouse_tiles_count: 0,
+ ...expected,
+ })
+ })
+
+ it('reports the dashboard group display', () => {
+ const withTabs = { ...dashboard([]), customization: { group_display: 'tabs' } } as unknown as DashboardType
+ expect(dashboardViewedProperties(withTabs, null, 'viewer')).toMatchObject({ group_display: 'tabs' })
+ })
+
+ const viewerCases: [string, { uuid: string } | null, string | undefined, boolean | undefined][] = [
+ ['the creator views it', { uuid: 'creator' }, 'creator', true],
+ ['another user views it', { uuid: 'creator' }, 'viewer', false],
+ ['the viewer is unknown', { uuid: 'creator' }, undefined, undefined],
+ ['the system created it', null, 'viewer', undefined],
+ ]
+
+ it.each(viewerCases)('reports viewer_is_creator when %s', (_, created_by, viewerUuid, expected) => {
+ const withCreator = { ...dashboard([]), created_by } as unknown as DashboardType
+ expect(dashboardViewedProperties(withCreator, null, viewerUuid)).toMatchObject({
+ viewer_is_creator: expected,
+ created_by_system: created_by === null,
+ })
+ })
+ })
+})
diff --git a/frontend/src/scenes/dashboard/dashboardEvents.ts b/frontend/src/scenes/dashboard/dashboardEvents.ts
new file mode 100644
index 000000000000..2c2bf51e438b
--- /dev/null
+++ b/frontend/src/scenes/dashboard/dashboardEvents.ts
@@ -0,0 +1,230 @@
+import posthog from 'posthog-js'
+
+import type { Dayjs } from 'lib/dayjs'
+import { now } from 'lib/dayjs'
+import { DashboardEventSource, filtersForAnalytics, sanitizeInsight } from 'lib/utils/eventUsageLogic'
+
+import { isNodeWithSource, queryUsesDataWarehouse } from '~/queries/utils'
+import { DashboardMode, DashboardTile, DashboardTileSpacing, DashboardType, TileGroupDisplay } from '~/types'
+
+export type DashboardFilterChangeType =
+ | 'date'
+ | 'properties'
+ | 'breakdown'
+ | 'variable'
+ | 'interval'
+ | 'test_accounts'
+ | 'compare'
+ | 'metric_variables'
+
+function sanitizeTile(tile: DashboardTile | null): object | undefined {
+ if (!tile) {
+ return undefined
+ }
+
+ return {
+ ...tile,
+ insight: tile.insight ? sanitizeInsight(tile.insight) : undefined,
+ }
+}
+
+export function sanitizeDashboard(dashboard: DashboardType | null): object | null {
+ if (!dashboard) {
+ return null
+ }
+
+ // Remove breakdown color entries, as they carry raw breakdown values (user data) — report only their count
+ const { breakdown_colors, ...sanitizedDashboard } = dashboard
+
+ return {
+ ...sanitizedDashboard,
+ breakdown_colors_count: breakdown_colors?.length ?? 0,
+ filters: filtersForAnalytics(dashboard.filters ?? {}),
+ persisted_filters: dashboard.persisted_filters ? filtersForAnalytics(dashboard.persisted_filters) : null,
+ tiles: dashboard.tiles?.map((tile) => sanitizeTile(tile)) || [],
+ }
+}
+
+function increment(counts: Record, key: string): void {
+ counts[key] = (counts[key] || 0) + 1
+}
+
+function insightTileCountKey(tile: DashboardTile): string {
+ const query = isNodeWithSource(tile.insight?.query) ? tile.insight.query.source : tile.insight?.query
+ return `${query?.kind || !!tile.text ? 'text' : 'empty'}_count`
+}
+
+function countDashboardTiles(tiles: DashboardTile[], properties: Record): void {
+ for (const tile of tiles) {
+ if (tile.insight) {
+ increment(properties, insightTileCountKey(tile))
+ properties.sample_items_count += tile.insight.is_sample ? 1 : 0
+ if (queryUsesDataWarehouse(tile.insight.query)) {
+ properties.uses_data_warehouse_source = true
+ properties.data_warehouse_tiles_count += 1
+ }
+ } else if (tile.widget) {
+ increment(properties, 'widget_tiles_count')
+ } else {
+ increment(properties, 'text_tiles_count')
+ }
+ }
+}
+
+export function tileGroupProperties(
+ groupDisplay: TileGroupDisplay | undefined,
+ tiles: DashboardTile[]
+): { group_display: TileGroupDisplay; group_count: number } {
+ return {
+ group_display: groupDisplay ?? 'sections',
+ group_count: new Set(tiles.map((tile) => tile.group_key).filter(Boolean)).size,
+ }
+}
+
+export function dashboardViewedProperties(
+ dashboard: DashboardType,
+ lastRefreshed: Dayjs | null,
+ viewerUuid: string | undefined
+): Record {
+ const { created_at, is_shared, pinned, creation_mode, id } = dashboard
+ const properties: Record = {
+ created_at,
+ is_shared,
+ pinned,
+ creation_mode,
+ viewer_is_creator:
+ dashboard.created_by?.uuid && viewerUuid ? dashboard.created_by.uuid === viewerUuid : undefined,
+ sample_items_count: 0,
+ item_count: dashboard.tiles?.length || 0,
+ created_by_system: !dashboard.created_by,
+ dashboard_id: id,
+ ...tileGroupProperties(dashboard.customization?.group_display, dashboard.tiles || []),
+ lastRefreshed: lastRefreshed?.toISOString(),
+ refreshAge: lastRefreshed ? now().diff(lastRefreshed, 'seconds') : undefined,
+ uses_data_warehouse_source: false,
+ data_warehouse_tiles_count: 0,
+ }
+ countDashboardTiles(dashboard.tiles || [], properties)
+ return properties
+}
+
+export function reportDashboardModeToggled(
+ dashboard: DashboardType | null,
+ mode: DashboardMode | null,
+ source: DashboardEventSource | null,
+ layoutZoom: number | null,
+ layoutEditMode?: boolean
+): void {
+ posthog.capture('dashboard mode toggled', {
+ dashboard_id: dashboard?.id,
+ dashboard: sanitizeDashboard(dashboard),
+ mode,
+ source,
+ layout_zoom: layoutZoom ?? undefined,
+ layout_edit_mode: layoutEditMode ?? undefined,
+ })
+}
+
+export function reportDashboardLayoutEditModeEntered(
+ dashboard: DashboardType | null,
+ source: DashboardEventSource,
+ layoutZoom: number | null
+): void {
+ posthog.capture('dashboard layout edit mode entered', {
+ dashboard_id: dashboard?.id,
+ dashboard: sanitizeDashboard(dashboard),
+ source,
+ layout_zoom: layoutZoom ?? undefined,
+ })
+}
+
+export function reportDashboardFiltersChanged(
+ dashboard: DashboardType | null,
+ changeType: DashboardFilterChangeType,
+ properties: Record
+): void {
+ posthog.capture('dashboard filters changed', {
+ dashboard_id: dashboard?.id,
+ change_type: changeType,
+ ...properties,
+ })
+}
+
+export function reportDashboardTileDensityConfigured(tileDensity: DashboardTileSpacing): void {
+ posthog.capture('dashboard tile density configured', { tile_density: tileDensity })
+}
+
+export function reportDashboardEditModeDiscardPrompt(
+ dashboard: DashboardType | null,
+ action: 'shown' | 'discarded' | 'kept_editing'
+): void {
+ posthog.capture('dashboard edit mode discard prompt', {
+ dashboard_id: dashboard?.id,
+ dashboard: sanitizeDashboard(dashboard),
+ action,
+ })
+}
+
+export function reportDashboardMoveInitiated(method: 'single' | 'bulk', count: number): void {
+ posthog.capture('dashboard move initiated', { method, count })
+}
+
+export function reportDashboardInsightDeleteAfterRemovalClicked(otherDashboardCount: number): void {
+ posthog.capture('dashboard insight delete after removal clicked', {
+ other_dashboard_count: otherDashboardCount,
+ })
+}
+
+export function reportDashboardTileRepositioned(
+ dashboardId: number,
+ action: 'moved' | 'resized',
+ layoutZoom: number
+): void {
+ posthog.capture('dashboard tile repositioned', {
+ dashboard_id: dashboardId,
+ action,
+ layout_zoom: layoutZoom,
+ })
+}
+
+export function reportDashboardInsightMetaUpdated(
+ dashboardId: number | undefined,
+ insightId: number,
+ attribute: 'name' | 'description'
+): void {
+ posthog.capture('dashboard insight meta updated', {
+ dashboard_id: dashboardId,
+ insight_id: insightId,
+ attribute,
+ })
+}
+
+/** Empty-state AI prompt chips (ai-first empty dashboard only). */
+export function reportDashboardEmptyAiPromptSubmitted(
+ dashboardId: number | undefined,
+ promptType: 'custom_prompt' | 'starter_question'
+): void {
+ posthog.capture('dashboard empty ai prompt submitted', {
+ dashboard_id: dashboardId,
+ prompt_type: promptType,
+ source: 'web',
+ })
+}
+
+export function reportDashboardEmptyAddChartClicked(dashboardId: number | undefined): void {
+ posthog.capture('dashboard empty add chart clicked', {
+ dashboard_id: dashboardId,
+ source: 'web',
+ })
+}
+
+export function reportDashboardEmptyWebAnalyticsClicked(dashboardId: number | undefined): void {
+ posthog.capture('dashboard empty web analytics clicked', {
+ dashboard_id: dashboardId,
+ source: 'web',
+ })
+}
+
+export function reportDashboardAddMenuOpened(source: 'header', dashboardId: number): void {
+ posthog.capture('dashboard add menu opened', { source, dashboard_id: dashboardId })
+}
diff --git a/frontend/src/scenes/dashboard/dashboardLogic.test.ts b/frontend/src/scenes/dashboard/dashboardLogic.test.ts
index 90b8e7eb59c3..9742f3b7c474 100644
--- a/frontend/src/scenes/dashboard/dashboardLogic.test.ts
+++ b/frontend/src/scenes/dashboard/dashboardLogic.test.ts
@@ -1604,10 +1604,7 @@ describe('dashboardLogic', () => {
it('previews tile spacing immediately and persists only the final choice', async () => {
await expectLogic(logic).toFinishAllListeners()
;(api.update as jest.Mock).mockClear()
- const reportTileDensityConfigured = jest.spyOn(
- eventUsageLogic.actions,
- 'reportDashboardTileDensityConfigured'
- )
+ const capture = jest.spyOn(posthog, 'capture').mockClear()
jest.useFakeTimers()
try {
@@ -1627,7 +1624,7 @@ describe('dashboardLogic', () => {
layout_compaction: DashboardGridCompaction.Vertical,
grid_spacing: 'relaxed',
})
- expect(reportTileDensityConfigured).toHaveBeenCalledWith('relaxed')
+ expect(capture).toHaveBeenCalledWith('dashboard tile density configured', { tile_density: 'relaxed' })
} finally {
jest.useRealTimers()
}
@@ -1635,10 +1632,7 @@ describe('dashboardLogic', () => {
it('reports the tile density when reset to standard', async () => {
await expectLogic(logic).toFinishAllListeners()
- const reportTileDensityConfigured = jest.spyOn(
- eventUsageLogic.actions,
- 'reportDashboardTileDensityConfigured'
- )
+ const capture = jest.spyOn(posthog, 'capture').mockClear()
jest.useFakeTimers()
try {
@@ -1647,7 +1641,7 @@ describe('dashboardLogic', () => {
await jest.advanceTimersByTimeAsync(750)
await expectLogic(logic).toFinishAllListeners()
- expect(reportTileDensityConfigured).toHaveBeenCalledWith('standard')
+ expect(capture).toHaveBeenCalledWith('dashboard tile density configured', { tile_density: 'standard' })
} finally {
jest.useRealTimers()
}
@@ -2992,12 +2986,8 @@ describe('dashboardLogic', () => {
})
it('reports filter changes separately from layout edit mode entry', async () => {
- const reportFiltersChanged = jest.spyOn(eventUsageLogic.actions, 'reportDashboardFiltersChanged')
- const reportLayoutEditEntered = jest.spyOn(
- eventUsageLogic.actions,
- 'reportDashboardLayoutEditModeEntered'
- )
- const reportModeToggled = jest.spyOn(eventUsageLogic.actions, 'reportDashboardModeToggled')
+ const capture = jest.spyOn(posthog, 'capture').mockClear()
+ const capturedEvents = (): string[] => capture.mock.calls.map(([event]) => event)
await expectLogic(logic).toFinishAllListeners()
@@ -3008,22 +2998,19 @@ describe('dashboardLogic', () => {
)
}).toFinishAllListeners()
- expect(reportLayoutEditEntered).not.toHaveBeenCalled()
- expect(reportModeToggled).not.toHaveBeenCalled()
+ expect(capturedEvents()).not.toContain('dashboard layout edit mode entered')
+ expect(capturedEvents()).not.toContain('dashboard mode toggled')
await expectLogic(logic, () => {
logic.actions.setDates('-7d', null)
}).toFinishAllListeners()
- expect(reportFiltersChanged).toHaveBeenCalledWith(
- expect.objectContaining({ id: 5 }),
- 'date',
- expect.objectContaining({ date_from: '-7d', date_to: null })
+ expect(capture).toHaveBeenCalledWith(
+ 'dashboard filters changed',
+ expect.objectContaining({ dashboard_id: 5, change_type: 'date', date_from: '-7d', date_to: null })
)
- reportFiltersChanged.mockClear()
- reportLayoutEditEntered.mockClear()
- reportModeToggled.mockClear()
+ capture.mockClear()
await expectLogic(logic, () => {
logic.actions.setDashboardEditing(
@@ -3032,17 +3019,18 @@ describe('dashboardLogic', () => {
)
}).toFinishAllListeners()
- expect(reportLayoutEditEntered).toHaveBeenCalledWith(
- expect.objectContaining({ id: 5 }),
- DashboardEventSource.SceneCommonButtons,
- 1
+ expect(capture).toHaveBeenCalledWith(
+ 'dashboard layout edit mode entered',
+ expect.objectContaining({
+ dashboard_id: 5,
+ source: DashboardEventSource.SceneCommonButtons,
+ layout_zoom: 1,
+ })
)
- expect(reportModeToggled).not.toHaveBeenCalled()
- expect(reportFiltersChanged).not.toHaveBeenCalled()
+ expect(capturedEvents()).not.toContain('dashboard mode toggled')
+ expect(capturedEvents()).not.toContain('dashboard filters changed')
- reportFiltersChanged.mockRestore()
- reportLayoutEditEntered.mockRestore()
- reportModeToggled.mockRestore()
+ capture.mockRestore()
})
it('filter edits preserve layout editing', async () => {
@@ -5936,10 +5924,7 @@ describe('dashboardLogic', () => {
it('offers to delete a removed insight and explains its other dashboard usage', async () => {
const { fireEvent, render } = await import('@testing-library/react')
- const reportDeleteClicked = jest.spyOn(
- eventUsageLogic.actions,
- 'reportDashboardInsightDeleteAfterRemovalClicked'
- )
+ const capture = jest.spyOn(posthog, 'capture').mockClear()
const insightTile = logic.values.insightTiles[0]
const insight = insightTile.insight!
@@ -5959,7 +5944,9 @@ describe('dashboardLogic', () => {
const toastContent = lemonToastInfoSpy.mock.calls.at(-1)?.[0]
const { getByText } = render(toastContent)
fireEvent.click(getByText('Delete insight everywhere'))
- expect(reportDeleteClicked).toHaveBeenCalledWith(1)
+ expect(capture).toHaveBeenCalledWith('dashboard insight delete after removal clicked', {
+ other_dashboard_count: 1,
+ })
expect(lemonToastDismissSpy).not.toHaveBeenCalled()
const dialogProps = lemonDialogOpenSpy.mock.calls.at(-1)?.[0]
@@ -5977,7 +5964,7 @@ describe('dashboardLogic', () => {
getDialogText('This deletes the insight and removes it from every dashboard. You can undo this action.')
).not.toBeNull()
- reportDeleteClicked.mockRestore()
+ capture.mockRestore()
})
})
diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx
index 2c652990b09e..1818e541e9af 100644
--- a/frontend/src/scenes/dashboard/dashboardLogic.tsx
+++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx
@@ -46,20 +46,23 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { accessLevelSatisfied } from 'lib/utils/accessControlUtils'
import { deleteInsightWithUndo } from 'lib/utils/deleteWithUndo'
import { clearDOMTextSelection, getJSHeapMemory, uuid } from 'lib/utils/dom'
+import { DashboardEventSource, eventUsageLogic, filtersForAnalytics, sanitizeQuery } from 'lib/utils/eventUsageLogic'
+import { objectsEqual } from 'lib/utils/objects'
+import { shouldCancelQuery } from 'lib/utils/requests'
+import { toParams } from 'lib/utils/url'
+import { addInsightToDashboardLogic } from 'scenes/dashboard/addInsightToDashboardModalLogic'
import {
- DashboardEventSource,
DashboardFilterChangeType,
dashboardViewedProperties,
- eventUsageLogic,
+ reportDashboardEditModeDiscardPrompt,
+ reportDashboardFiltersChanged,
+ reportDashboardInsightDeleteAfterRemovalClicked,
+ reportDashboardLayoutEditModeEntered,
+ reportDashboardModeToggled,
+ reportDashboardTileDensityConfigured,
sanitizeDashboard,
- filtersForAnalytics,
- sanitizeQuery,
tileGroupProperties,
-} from 'lib/utils/eventUsageLogic'
-import { objectsEqual } from 'lib/utils/objects'
-import { shouldCancelQuery } from 'lib/utils/requests'
-import { toParams } from 'lib/utils/url'
-import { addInsightToDashboardLogic } from 'scenes/dashboard/addInsightToDashboardModalLogic'
+} from 'scenes/dashboard/dashboardEvents'
import { BREAKPOINTS, dashboardToSaveableTemplate, getDashboardTileDisplayName } from 'scenes/dashboard/dashboardUtils'
import { liveDashboardIds } from 'scenes/dashboard/liveDashboardIds'
import { calculateDuplicateLayout, calculateLayouts } from 'scenes/dashboard/tileLayouts'
@@ -4985,9 +4988,7 @@ export const dashboardLogic = kea([
data-attr="delete-removed-insight"
className="!m-0"
onClick={() => {
- eventUsageLogic.actions.reportDashboardInsightDeleteAfterRemovalClicked(
- otherDashboardIds.size
- )
+ reportDashboardInsightDeleteAfterRemovalClicked(otherDashboardIds.size)
deleteInsight()
}}
>
@@ -5126,7 +5127,7 @@ export const dashboardLogic = kea([
dashboardsModel.actions.updateDashboardSuccess(dashboard, {
layoutsPersisted: true,
})
- eventUsageLogic.actions.reportDashboardTileDensityConfigured(tileSpacing)
+ reportDashboardTileDensityConfigured(tileSpacing)
} catch {
if (!cache.pendingDashboardTileSpacing) {
actions.setDashboardTileSpacing(persistedTileSpacing)
@@ -5923,7 +5924,7 @@ export const dashboardLogic = kea([
discard()
return
}
- eventUsageLogic.actions.reportDashboardEditModeDiscardPrompt(values.dashboard, 'shown')
+ reportDashboardEditModeDiscardPrompt(values.dashboard, 'shown')
LemonDialog.open({
title: 'Discard unsaved changes?',
description:
@@ -5932,7 +5933,7 @@ export const dashboardLogic = kea([
children: 'Discard changes',
status: 'danger',
onClick: () => {
- eventUsageLogic.actions.reportDashboardEditModeDiscardPrompt(values.dashboard, 'discarded')
+ reportDashboardEditModeDiscardPrompt(values.dashboard, 'discarded')
discard()
},
'data-attr': 'dashboard-edit-mode-discard-confirm',
@@ -5940,7 +5941,7 @@ export const dashboardLogic = kea([
secondaryButton: {
children: 'Keep editing',
onClick: () => {
- eventUsageLogic.actions.reportDashboardEditModeDiscardPrompt(values.dashboard, 'kept_editing')
+ reportDashboardEditModeDiscardPrompt(values.dashboard, 'kept_editing')
},
},
})
@@ -6017,7 +6018,7 @@ export const dashboardLogic = kea([
}
if (editing?.layout) {
- eventUsageLogic.actions.reportDashboardLayoutEditModeEntered(
+ reportDashboardLayoutEditModeEntered(
values.dashboard,
source,
values.layoutEditMode ? values.layoutZoom : null
@@ -6026,7 +6027,7 @@ export const dashboardLogic = kea([
},
setDashboardMode: async ({ mode, source }) => {
if (mode || source) {
- eventUsageLogic.actions.reportDashboardModeToggled(
+ reportDashboardModeToggled(
values.dashboard,
mode,
source,
@@ -6183,7 +6184,7 @@ export const dashboardLogic = kea([
remainingInsightIds: new Set(values.insightTiles.map((tile) => tile.insight!.short_id)),
}
cache.pendingFilterChange = filterChange
- eventUsageLogic.actions.reportDashboardFiltersChanged(values.dashboard, changeType, {
+ reportDashboardFiltersChanged(values.dashboard, changeType, {
...properties,
filter_change_id: filterChange.id,
supersedes_filter_change_id: previousChange?.id,
diff --git a/frontend/src/scenes/dashboard/dashboards/dashboardsLogic.ts b/frontend/src/scenes/dashboard/dashboards/dashboardsLogic.ts
index bf9925ed692a..72d0b53253e8 100644
--- a/frontend/src/scenes/dashboard/dashboards/dashboardsLogic.ts
+++ b/frontend/src/scenes/dashboard/dashboards/dashboardsLogic.ts
@@ -8,8 +8,8 @@ import { Sorting } from 'lib/lemon-ui/LemonTable/sorting'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { buildUserScopedPersistenceConfig } from 'lib/logic/persistence'
import { trackedActionToUrl } from 'lib/logic/scenes/trackedActionToUrl'
-import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { objectClean, objectsEqual } from 'lib/utils/objects'
+import { reportDashboardMoveInitiated } from 'scenes/dashboard/dashboardEvents'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
@@ -160,13 +160,6 @@ export interface dashboardsLogicActions {
onStillSelected: (ids: number[]) => void
}
} // dashboardsModel
- reportDashboardMoveInitiated: (
- method: 'bulk' | 'single',
- count: number
- ) => {
- count: number
- method: 'bulk' | 'single'
- } // eventUsageLogic
openMoveToModal: (items: FileSystemEntry[]) => {
items: FileSystemEntry[]
} // moveToLogic
@@ -316,8 +309,6 @@ export const dashboardsLogic = kea([
['bulkDeleteDashboards', 'bulkDeleteDashboardsSuccess', 'reloadDashboardsSuccess'],
moveToLogic,
['openMoveToModal'],
- eventUsageLogic,
- ['reportDashboardMoveInitiated'],
projectTreeDataLogic,
['movedItem'],
],
@@ -775,7 +766,7 @@ export const dashboardsLogic = kea([
if (moving.length === 0) {
return
}
- actions.reportDashboardMoveInitiated(method, moving.length)
+ reportDashboardMoveInitiated(method, moving.length)
actions.openMoveToModal(moving)
cache.awaitingMove = { ids: new Set(ids), onStillSelected }
},
diff --git a/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.test.ts b/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.test.ts
index d3a87620eeec..2af8eef2b0cf 100644
--- a/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.test.ts
+++ b/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.test.ts
@@ -1,6 +1,17 @@
import { CachedNewExperimentQueryResponse } from '~/queries/schema/schema-general'
-import { calculateCurrentExposures, calculateDaysElapsed, calculateExposureRate } from './calculations'
+import type { RunningTimeOutlookApi } from 'products/experiments/frontend/generated/api.schemas'
+
+import {
+ buildRunningTimeOutlook,
+ calculateCurrentExposures,
+ calculateDaysElapsed,
+ DetectableEffectCheckpoint,
+ describeCheckpointDay,
+ describeUseButton,
+ formatDetectableEffect,
+ RunningTimeOutlook,
+} from './calculations'
// The sample-size / variance math is verified in the backend
// (products/experiments/backend/test/test_running_time_calculator.py). These tests
@@ -20,20 +31,6 @@ describe('running time calculations', () => {
})
})
- describe('calculateExposureRate', () => {
- it('returns exposures per day', () => {
- expect(calculateExposureRate(1000, 10)).toBe(100)
- })
-
- it('returns null below the minimum elapsed window', () => {
- expect(calculateExposureRate(1000, 0.05)).toBeNull()
- })
-
- it('returns null without exposures', () => {
- expect(calculateExposureRate(null, 10)).toBeNull()
- })
- })
-
describe('calculateDaysElapsed', () => {
it('returns null without a start date', () => {
expect(calculateDaysElapsed(null)).toBeNull()
@@ -45,4 +42,174 @@ describe('running time calculations', () => {
expect(elapsed!).toBeGreaterThan(0)
})
})
+
+ describe('buildRunningTimeOutlook', () => {
+ // Two weeks in at about 143 exposures a day, as the backend projects it.
+ const PROJECTION: RunningTimeOutlookApi = {
+ days_since_launch: 14,
+ exposures_per_day: 2000 / 14,
+ detectable_effect_now: 126.5,
+ upcoming_checkpoints: [
+ { weeks_since_launch: 4, detectable_effect: 89.4 },
+ { weeks_since_launch: 6, detectable_effect: 73 },
+ { weeks_since_launch: 8, detectable_effect: 63.2 },
+ ],
+ reachable_effect: 73,
+ }
+ const outlook = (
+ overrides: Partial[0]>
+ ): RunningTimeOutlook | null =>
+ buildRunningTimeOutlook({
+ projection: PROJECTION,
+ currentSampleSize: 2000,
+ savedEffect: 30,
+ savedSampleSize: 35556,
+ defaultEffect: 30,
+ enteredEffect: null,
+ selectedKey: null,
+ minimumDetectableEffect: 30,
+ ...overrides,
+ })
+ const rowsOf = (result: RunningTimeOutlook): string[] =>
+ result.checkpoints.map(({ kind, isTarget }) => (isTarget ? `${kind} (target)` : kind))
+
+ it('lays out today, the week marks and the custom row at the saved effect', () => {
+ const result = outlook({})!
+
+ expect(
+ result.checkpoints.map(({ kind, days, exposures, detectableEffect, isTarget }) => [
+ kind,
+ Math.round(days),
+ exposures,
+ detectableEffect,
+ isTarget,
+ ])
+ ).toEqual([
+ ['now', 14, 2000, 126.5, false],
+ ['week', 28, 4000, 89.4, false],
+ ['week', 42, 6000, 73, false],
+ ['week', 56, 8000, 63.2, false],
+ ['custom', 249, 35556, 30, true],
+ ])
+ expect(result.daysToTarget).toBeCloseTo(248.9, 1)
+ })
+
+ it.each([
+ [
+ '"Use" chooses a week mark',
+ { selectedKey: 'week-6' },
+ ['now', 'week', 'week (target)', 'week', 'custom'],
+ 30,
+ 42,
+ ],
+ ['"Use" chooses today', { selectedKey: 'now' }, ['now (target)', 'week', 'week', 'week', 'custom'], 30, 14],
+ [
+ 'the slider moves the custom row',
+ { enteredEffect: 50 },
+ ['now', 'week', 'week', 'week', 'custom (target)'],
+ 50,
+ 89.6,
+ ],
+ ])('keeps the same rows when %s', (_name, overrides, rows, customEffect, daysToTarget) => {
+ const result = outlook(overrides)!
+
+ expect(rowsOf(result)).toEqual(rows)
+ expect(result.checkpoints.find(({ kind }) => kind === 'custom')?.detectableEffect).toEqual(customEffect)
+ expect(result.daysToTarget).toBeCloseTo(daysToTarget, 1)
+ })
+
+ it('reopens on the row whose effect was saved, with the custom row at the default effect', () => {
+ const result = outlook({ savedEffect: 73, savedSampleSize: 5990, minimumDetectableEffect: 73 })!
+
+ expect(rowsOf(result)).toEqual(['now', 'week', 'week (target)', 'week', 'custom'])
+ expect(result.checkpoints.find(({ kind }) => kind === 'custom')?.detectableEffect).toEqual(30)
+ })
+
+ it.each([
+ [80, false],
+ [73, false],
+ [30, true],
+ ])('puts a %s%% target out of reach: %s', (minimumDetectableEffect, isOutOfReach) => {
+ expect(outlook({ minimumDetectableEffect })!.isOutOfReach).toBe(isOutOfReach)
+ })
+
+ it.each([
+ ['no projection', { projection: null }],
+ ['no saved target', { savedSampleSize: null }],
+ ['no exposures so far', { currentSampleSize: null }],
+ ])('gives no outlook without %s', (_name, overrides) => {
+ expect(outlook(overrides)).toBeNull()
+ })
+ })
+
+ describe('describeCheckpointDay', () => {
+ const checkpoint = (overrides: Partial): DetectableEffectCheckpoint => ({
+ key: 'custom',
+ kind: 'custom',
+ days: 18,
+ exposures: 3341,
+ detectableEffect: 30,
+ isTarget: false,
+ ...overrides,
+ })
+
+ beforeEach(() => {
+ jest.useFakeTimers().setSystemTime(new Date('2026-10-27T12:00:00Z'))
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it.each([
+ ['a week mark', { kind: 'week' as const, days: 14 }, 'week 2'],
+ ['today', { kind: 'now' as const, days: 11.17, exposures: 2000 }, 'day 12'],
+ ['the custom row before it is reached', {}, 'in 7 days'],
+ ['the custom row reached earlier today', { days: 11, exposures: 1970 }, 'day 12'],
+ ])('describes %s', (_name, overrides, description) => {
+ expect(
+ describeCheckpointDay(checkpoint(overrides), {
+ startDate: '2026-10-16T08:00:00Z',
+ currentSampleSize: 2000,
+ })
+ ).toEqual(description)
+ })
+ })
+
+ describe('describeUseButton', () => {
+ it.each([
+ [
+ 'the effect of a week mark as the table shows it',
+ { kind: 'week' as const, detectableEffect: 32.74 },
+ { effect: 32.8, tooltip: 'Set 32.8% as the minimum detectable effect' },
+ ],
+ [
+ 'the custom effect as it is',
+ { kind: 'custom' as const, detectableEffect: 12.345 },
+ { effect: 12.345, tooltip: 'Set 12.345% as the minimum detectable effect' },
+ ],
+ [
+ 'only why an effect over 100% cannot be set',
+ { kind: 'now' as const, detectableEffect: 126.5 },
+ { effect: 127, disabledReason: 'The minimum detectable effect can be at most 100%' },
+ ],
+ ])('offers %s', (_name, overrides, button) => {
+ expect(describeUseButton({ key: 'row', days: 14, exposures: 2000, isTarget: false, ...overrides })).toEqual(
+ button
+ )
+ })
+ })
+
+ describe('formatDetectableEffect', () => {
+ it.each([
+ [63.21, '63.3%'],
+ [30.000000001, '30%'],
+ [8.714, '8.72%'],
+ [100, '100%'],
+ [100.01, '>100%'],
+ [126.49, '>100%'],
+ ])('formats the detectable change %s as %s', (detectableEffect, formatted) => {
+ expect(formatDetectableEffect(detectableEffect)).toEqual(formatted)
+ })
+ })
})
diff --git a/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.ts b/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.ts
index 829a5d07bed4..7d4804fff640 100644
--- a/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.ts
+++ b/frontend/src/scenes/experiments/RunningTimeCalculator/calculations.ts
@@ -2,9 +2,12 @@ import { dayjs } from 'lib/dayjs'
import type { CachedNewExperimentQueryResponse } from '~/queries/schema/schema-general'
-// The target sample lives in the backend (products/experiments/backend/running_time_target.py), reached via
-// GET /experiments/:id/running_time_target, and the manual preview's math via POST
-// /experiments/calculate_running_time. This module keeps only the client-side helpers that read live experiment state.
+import type { RunningTimeOutlookApi } from 'products/experiments/frontend/generated/api.schemas'
+
+// The target sample and its outlook live in the backend (products/experiments/backend/running_time_target.py), reached
+// via GET /experiments/:id/running_time_target, and the manual preview's math via POST
+// /experiments/calculate_running_time. This module keeps the helpers that read live experiment state, rescale the target
+// for an effect being edited, and place that target on the backend's outlook.
// Manual calculator only supports these types (ratio/retention require full baseline data).
export type ManualCalculatorMetricType = 'funnel' | 'mean_count' | 'mean_sum_or_avg'
@@ -28,13 +31,9 @@ export function calculateDaysElapsed(startDate: string | null): number | null {
return dayjs().diff(dayjs(startDate), 'days', true) // fractional days
}
-export function calculateExposureRate(currentExposures: number | null, daysElapsed: number | null): number | null {
- if (!currentExposures || !daysElapsed || daysElapsed < 0.1) {
- return null
- }
-
- return currentExposures / daysElapsed
-}
+// The minimum detectable effects, as a percentage, that the running time settings accept.
+export const MINIMUM_DETECTABLE_EFFECT_MIN = 1
+export const MINIMUM_DETECTABLE_EFFECT_MAX = 100
/**
* The target sample for another minimum detectable effect, by the rule the target endpoint documents: the variances come
@@ -48,3 +47,182 @@ export function rescaleSampleSize(sampleSize: number, fromEffect: number, toEffe
// Round before the ceiling, as the backend does, so that float noise doesn't turn an exact 3,200 into 3,201.
return Math.ceil(Number((sampleSize * (fromEffect / toEffect) ** 2).toFixed(6)))
}
+
+export interface DetectableEffectCheckpoint {
+ key: string
+ kind: 'now' | 'week' | 'custom'
+ // Since launch.
+ days: number
+ // So far for today, projected for the week marks, and the target sample for the custom row.
+ exposures: number
+ // The smallest relative change, as a percentage, that the exposures by then can detect.
+ detectableEffect: number
+ isTarget: boolean
+}
+
+export interface RunningTimeOutlook {
+ // Since launch, to the row the user chose as the target.
+ daysToTarget: number
+ isOutOfReach: boolean
+ // Today, the backend's next week marks and the custom row, by date.
+ checkpoints: DetectableEffectCheckpoint[]
+ // The smallest change detectable within four weeks from today, which the header shows for a target out of reach.
+ reachableEffect: number
+}
+
+/**
+ * Rounded up to three significant figures, because the exposures can't detect a smaller change than the exact value.
+ * Set as the minimum detectable effect, the rounded value needs at most 2% fewer exposures than the row it came from.
+ */
+export function roundUpDetectableEffect(detectableEffect: number): number {
+ if (!(detectableEffect > 0)) {
+ return detectableEffect
+ }
+ const precision = 10 ** (2 - Math.floor(Math.log10(detectableEffect)))
+ // Round before the ceiling, so that float noise doesn't turn an exact 30 into 30.1.
+ return Math.ceil(Number((detectableEffect * precision).toFixed(6))) / precision
+}
+
+/**
+ * The backend's projection of what the experiment can detect over time, as a fixed set of rows: today, the next week
+ * marks, and a custom row for the effect that the slider sets. The slider moves the custom row, and "Use" chooses a row
+ * as the target, so no interaction adds or removes a row. Every row sits where the projection reaches its exposures.
+ */
+export function buildRunningTimeOutlook({
+ projection,
+ currentSampleSize,
+ savedEffect,
+ savedSampleSize,
+ defaultEffect,
+ enteredEffect,
+ selectedKey,
+ minimumDetectableEffect,
+}: {
+ projection: RunningTimeOutlookApi | null
+ currentSampleSize: number | null
+ // The effect the backend sized its target for, and that target.
+ savedEffect: number | null
+ savedSampleSize: number | null
+ defaultEffect: number
+ // The last effect from the slider or the input, null until the user changes it.
+ enteredEffect: number | null
+ // The row the user chose with "Use", null when the custom row is the target.
+ selectedKey: string | null
+ minimumDetectableEffect: number
+}): RunningTimeOutlook | null {
+ if (!projection || currentSampleSize === null || !savedEffect || !savedSampleSize) {
+ return null
+ }
+ const rate = projection.exposures_per_day
+ const now: DetectableEffectCheckpoint = {
+ key: 'now',
+ kind: 'now',
+ days: projection.days_since_launch,
+ exposures: currentSampleSize,
+ detectableEffect: projection.detectable_effect_now,
+ isTarget: false,
+ }
+ const weeks = projection.upcoming_checkpoints.map(
+ (checkpoint): DetectableEffectCheckpoint => ({
+ key: `week-${checkpoint.weeks_since_launch}`,
+ kind: 'week',
+ days: checkpoint.weeks_since_launch * 7,
+ exposures: Math.round(rate * checkpoint.weeks_since_launch * 7),
+ detectableEffect: checkpoint.detectable_effect,
+ isTarget: false,
+ })
+ )
+ // A saved effect that today or a week mark shows came from that row's "Use", so that row is the target. The custom
+ // row then starts at the default effect rather than repeat the row.
+ const savedRow = [now, ...weeks].find(
+ (checkpoint) => roundUpDetectableEffect(checkpoint.detectableEffect) === roundUpDetectableEffect(savedEffect)
+ )
+ const customEffect = enteredEffect ?? (savedRow ? defaultEffect : savedEffect)
+ const customSampleSize = rescaleSampleSize(savedSampleSize, savedEffect, customEffect)
+ if (!customSampleSize) {
+ return null
+ }
+ const custom: DetectableEffectCheckpoint = {
+ key: 'custom',
+ kind: 'custom',
+ days: customSampleSize / rate,
+ exposures: customSampleSize,
+ detectableEffect: customEffect,
+ isTarget: false,
+ }
+ const rows = [now, ...weeks, custom]
+ const targetKey = selectedKey ?? (enteredEffect === null && savedRow ? savedRow.key : custom.key)
+ const target = rows.find((checkpoint) => checkpoint.key === targetKey) ?? custom
+
+ return {
+ daysToTarget: target.days,
+ isOutOfReach: minimumDetectableEffect < projection.reachable_effect,
+ checkpoints: rows
+ .map((checkpoint) => (checkpoint === target ? { ...checkpoint, isTarget: true } : checkpoint))
+ .sort((a, b) => a.days - b.days),
+ reachableEffect: projection.reachable_effect,
+ }
+}
+
+/**
+ * Calendar days from today to a day since launch, at least one, so that the header and the table count alike.
+ */
+export function daysFromToday(startDate: string, daysSinceLaunch: number): number {
+ const date = dayjs(startDate).add(daysSinceLaunch * 24, 'hour')
+ return Math.max(1, date.startOf('day').diff(dayjs().startOf('day'), 'day'))
+}
+
+export function describeCheckpointDay(
+ checkpoint: DetectableEffectCheckpoint,
+ { startDate, currentSampleSize }: { startDate: string; currentSampleSize: number }
+): string {
+ if (checkpoint.kind === 'week') {
+ return `week ${checkpoint.days / 7}`
+ }
+ if (checkpoint.kind === 'custom' && checkpoint.exposures > currentSampleSize) {
+ const days = daysFromToday(startDate, checkpoint.days)
+ return `in ${days.toLocaleString()} ${days === 1 ? 'day' : 'days'}`
+ }
+ // Calendar days, like the dates beside them, so that rows on one date show one day and the launch date is day 1.
+ const launch = dayjs(startDate)
+ const day =
+ launch
+ .add(checkpoint.days * 24, 'hour')
+ .startOf('day')
+ .diff(launch.startOf('day'), 'day') + 1
+ return `day ${day.toLocaleString()}`
+}
+
+export function describeUseButton(checkpoint: DetectableEffectCheckpoint): {
+ effect: number
+ disabledReason?: string
+ tooltip?: string
+} {
+ const effect =
+ checkpoint.kind === 'custom'
+ ? checkpoint.detectableEffect
+ : roundUpDetectableEffect(checkpoint.detectableEffect)
+ if (effect > MINIMUM_DETECTABLE_EFFECT_MAX) {
+ return {
+ effect,
+ disabledReason: `The minimum detectable effect can be at most ${MINIMUM_DETECTABLE_EFFECT_MAX}%`,
+ }
+ }
+ if (effect < MINIMUM_DETECTABLE_EFFECT_MIN) {
+ return {
+ effect,
+ disabledReason: `The minimum detectable effect must be at least ${MINIMUM_DETECTABLE_EFFECT_MIN}%`,
+ }
+ }
+ return { effect, tooltip: `Set ${effect.toLocaleString()}% as the minimum detectable effect` }
+}
+
+/**
+ * Past the largest effect the settings accept, the exact value is too large to act on, so it shows as ">100%".
+ */
+export function formatDetectableEffect(detectableEffect: number): string {
+ const effect = roundUpDetectableEffect(detectableEffect)
+ return effect > MINIMUM_DETECTABLE_EFFECT_MAX
+ ? `>${MINIMUM_DETECTABLE_EFFECT_MAX}%`
+ : `${effect.toLocaleString(undefined, { maximumSignificantDigits: 3 })}%`
+}
diff --git a/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.test.ts b/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.test.ts
index bddda9c4b730..d6a099830ca5 100644
--- a/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.test.ts
+++ b/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.test.ts
@@ -1,6 +1,7 @@
import { api } from 'lib/api.mock'
import { expectLogic } from 'kea-test-utils'
+import posthog from 'posthog-js'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
@@ -11,6 +12,7 @@ import { Experiment } from '~/types'
import { experimentLogic } from '../experimentLogic'
import { experimentMetricsLogic } from '../experimentMetricsLogic'
import { modalsLogic } from '../modalsLogic'
+import { roundUpDetectableEffect } from './calculations'
import { runningTimeLogic } from './runningTimeLogic'
jest.mock('lib/lemon-ui/LemonToast/LemonToast', () => ({
@@ -61,6 +63,15 @@ const metricResultWithBaseline = {
variant_results: [{ key: 'test', number_of_samples: 600, sum: 130, sum_squares: 52 }],
}
+// The backend's projection for the result above: 1,200 exposures over ten days.
+const PROJECTION = {
+ days_since_launch: 10,
+ exposures_per_day: 120,
+ detectable_effect_now: 38.7,
+ upcoming_checkpoints: [{ weeks_since_launch: 2, detectable_effect: 32.7 }],
+ reachable_effect: 25.4,
+}
+
// The latest completed run covers the one primary metric, so the metrics logic applies the baseline on mount.
const latestRecalculation = {
id: 'recalc-1',
@@ -123,6 +134,7 @@ describe('runningTimeLogic', () => {
runningTimeTargetMock.mockResolvedValue({
recommended_sample_size: 2000,
minimum_detectable_effect: 30,
+ outlook: PROJECTION,
})
api.update.mockResolvedValue({
...experiment,
@@ -172,6 +184,7 @@ describe('runningTimeLogic', () => {
runningTimeTargetMock.mockResolvedValue({
recommended_sample_size: 2000,
minimum_detectable_effect: 30,
+ outlook: PROJECTION,
})
api.update.mockRejectedValue({
status: 409,
@@ -202,7 +215,7 @@ describe('runningTimeLogic', () => {
logic = runningTimeLogic({ experiment })
logic.mount()
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
runningTimeTargetMock.mockClear()
logic.actions.setConfig({ mde: 15 })
@@ -223,18 +236,75 @@ describe('runningTimeLogic', () => {
logic = runningTimeLogic({ experiment })
logic.mount()
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
expect(logic.values.currentExposures).toEqual(1100)
})
+ it('counts the days left on the projection, also before 100 exposures and with results from before today', async () => {
+ // 90 exposures in results that end on day 9 make 10 a day, so the 180 of the target arrive on day 18.
+ runningTimeTargetMock.mockResolvedValue({
+ recommended_sample_size: 180,
+ minimum_detectable_effect: 30,
+ current_sample_size: 90,
+ outlook: { ...PROJECTION, days_since_launch: 10, exposures_per_day: 10 },
+ })
+ api.update.mockResolvedValue({ ...experiment, version: 4 })
+
+ logic = runningTimeLogic({ experiment })
+ logic.mount()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
+
+ expect(logic.values.remainingDays).toEqual(8)
+ })
+
+ it('keeps the same rows while the user takes a row with "Use" and moves the slider', async () => {
+ // At 120 exposures a day, the 1,996 that 30% needs arrive after week 2, which can detect 32.7%.
+ runningTimeTargetMock.mockResolvedValue({
+ recommended_sample_size: 1996,
+ minimum_detectable_effect: 30,
+ current_sample_size: 1200,
+ outlook: PROJECTION,
+ })
+ api.update.mockResolvedValue({ ...experiment, version: 4 })
+ logic = runningTimeLogic({ experiment })
+ logic.mount()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
+ const rows = (): [number, boolean][] | undefined =>
+ logic.values.outlook?.checkpoints.map(({ detectableEffect, isTarget }) => [detectableEffect, isTarget])
+
+ expect(rows()).toEqual([
+ [38.7, false],
+ [32.7, false],
+ [30, true],
+ ])
+
+ logic.actions.selectCheckpoint('week-2', roundUpDetectableEffect(32.7))
+
+ expect(rows()).toEqual([
+ [38.7, false],
+ [32.7, true],
+ [30, false],
+ ])
+ expect(logic.values.remainingDays).toEqual(4)
+ expect(logic.values.config.mde).toEqual(32.7)
+
+ logic.actions.setConfig({ mde: 36 })
+
+ expect(rows()).toEqual([
+ [38.7, false],
+ [36, true],
+ [32.7, false],
+ ])
+ })
+
it('asks again when the metric sums change without new exposures', async () => {
runningTimeTargetMock.mockResolvedValue({ recommended_sample_size: 2000, minimum_detectable_effect: 30 })
api.update.mockResolvedValue({ ...experiment, version: 4 })
logic = runningTimeLogic({ experiment })
logic.mount()
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
metricsLogicInstance.actions.setPrimaryMetricsResults([
{
@@ -242,7 +312,7 @@ describe('runningTimeLogic', () => {
baseline: { ...metricResultWithBaseline.baseline, sum: 150, sum_squares: 90 },
} as any,
])
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculation']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTarget']).toFinishAllListeners()
expect(runningTimeTargetMock).toHaveBeenCalledTimes(2)
})
@@ -253,6 +323,7 @@ describe('runningTimeLogic', () => {
runningTimeTargetMock.mockResolvedValueOnce({
recommended_sample_size: 2000,
minimum_detectable_effect: 30,
+ outlook: PROJECTION,
})
api.update.mockImplementation(async (_url: string, payload: unknown) => ({
...experiment,
@@ -260,19 +331,22 @@ describe('runningTimeLogic', () => {
version: 4,
}))
+ const captureSpy = jest.spyOn(posthog, 'capture')
logic = runningTimeLogic({ experiment })
logic.mount()
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
+ const savedMde = logic.values.config.mde
modalsLogic.actions.openRunningTimeConfigModal()
logic.actions.setConfig({ mde: 15 })
runningTimeTargetMock.mockResolvedValueOnce({
recommended_sample_size: 8000,
minimum_detectable_effect: 15,
+ outlook: PROJECTION,
})
api.update.mockClear()
await expectLogic(logic, () => logic.actions.save())
- .toDispatchActions(['loadAutomaticCalculationSuccess'])
+ .toDispatchActions(['loadAutomaticTargetSuccess'])
.toFinishAllListeners()
expect(api.update).toHaveBeenLastCalledWith(
@@ -284,6 +358,11 @@ describe('runningTimeLogic', () => {
}),
})
)
+ expect(captureSpy).toHaveBeenCalledWith(
+ 'experiment running time settings saved',
+ expect.objectContaining({ minimum_detectable_effect: 15, previous_minimum_detectable_effect: savedMde })
+ )
+ captureSpy.mockRestore()
})
it('keeps the modal open with the edited config when the experiment save fails', async () => {
@@ -308,7 +387,14 @@ describe('runningTimeLogic', () => {
})
describe('isCalculating', () => {
- it('stays true while the automatic estimate is in flight, then settles on success', async () => {
+ it.each([
+ ['an automatic experiment', {}, null],
+ [
+ 'a manual experiment switched to automatic',
+ { exposure_estimate_config: { conversionRateInputType: 'manual' } },
+ { mode: 'automatic' as const },
+ ],
+ ])('stays true while the estimate of %s is in flight, then settles', async (_name, saved, edit) => {
let resolveCalc: (value: unknown) => void = () => {}
runningTimeTargetMock.mockReturnValue(
new Promise((resolve) => {
@@ -316,15 +402,22 @@ describe('runningTimeLogic', () => {
})
)
api.update.mockResolvedValue({ ...experiment, version: 4 })
+ experimentLogicInstance.actions.setExperiment({
+ ...experiment,
+ running_time_calculation: saved as Experiment['running_time_calculation'],
+ })
logic = runningTimeLogic({ experiment })
logic.mount()
+ if (edit) {
+ logic.actions.setConfig(edit)
+ }
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculation'])
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTarget'])
expect(logic.values.isCalculating).toBe(true)
resolveCalc({ recommended_sample_size: 2000, minimum_detectable_effect: 30 })
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
expect(logic.values.isCalculating).toBe(false)
})
@@ -336,7 +429,7 @@ describe('runningTimeLogic', () => {
logic = runningTimeLogic({ experiment })
logic.mount()
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationFailure']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetFailure']).toFinishAllListeners()
expect(logic.values.isCalculating).toBe(false)
})
@@ -344,12 +437,13 @@ describe('runningTimeLogic', () => {
runningTimeTargetMock.mockResolvedValue({
recommended_sample_size: 2000,
minimum_detectable_effect: 30,
+ outlook: PROJECTION,
})
logic = runningTimeLogic({ experiment })
logic.mount()
// The baseline fires the automatic estimate on mount; let it settle so only the recalculation state moves.
- await expectLogic(logic).toDispatchActions(['loadAutomaticCalculationSuccess']).toFinishAllListeners()
+ await expectLogic(logic).toDispatchActions(['loadAutomaticTargetSuccess']).toFinishAllListeners()
expect(logic.values.isCalculating).toBe(false)
metricsLogicInstance.actions.setRecalculationLoading(true)
diff --git a/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.ts b/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.ts
index 1c032901e282..7389cbb6bc7f 100644
--- a/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.ts
+++ b/frontend/src/scenes/experiments/RunningTimeCalculator/runningTimeLogic.ts
@@ -1,6 +1,7 @@
import { MakeLogicType, actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { subscriptions } from 'kea-subscriptions'
+import posthog from 'posthog-js'
import api from 'lib/api'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
@@ -9,7 +10,7 @@ import { objectsEqual } from 'lib/utils/objects'
import { experimentsConfigLogic } from '~/scenes/settings/environment/experimentsConfigLogic'
import { ConversionRateInputType, Experiment } from '~/types'
-import { isLaunched } from 'products/experiments/frontend/experimentStatus'
+import { isExperimentExposureFrozen, isLaunched } from 'products/experiments/frontend/experimentStatus'
import {
experimentsCalculateRunningTimeCreate,
experimentsRunningTimeTargetRetrieve,
@@ -22,15 +23,18 @@ import type {
import type { CachedNewExperimentQueryResponse } from '../../../queries/schema/schema-general'
import { experimentLogic, saveExperimentUpdate } from '../experimentLogic'
import { experimentMetricsLogic } from '../experimentMetricsLogic'
+import { getDefaultMetricTitle } from '../MetricsView/shared/utils'
import { modalsLogic } from '../modalsLogic'
import { getFlagVariants, metricResults, isExperimentConflictError, toConcurrencyPayload } from '../utils'
import type { MetricWithResult } from '../utils'
import {
ManualCalculatorMetricType,
calculateCurrentExposures,
+ buildRunningTimeOutlook,
calculateDaysElapsed,
- calculateExposureRate,
+ daysFromToday,
rescaleSampleSize,
+ RunningTimeOutlook,
} from './calculations'
export interface RunningTimeLogicProps {
@@ -50,6 +54,14 @@ export interface ManualPreview {
runningTime: number | null
}
+// The metric the automatic estimate is sized from.
+export interface EstimateMetric {
+ name: string
+ // The experiment has more than one primary metric, and the estimate reads the first.
+ isFirstOfSeveral: boolean
+ hasOtherMetrics: boolean
+}
+
// The backend sizes the target for the saved minimum detectable effect. The settings rescale it for an edited one, so an
// edit sends no request.
export interface AutomaticTargetRequest {
@@ -68,17 +80,22 @@ export interface runningTimeLogicValues {
primaryMetricsResultsErrors: (unknown | null)[] // experimentMetricsLogic
defaultMinimumDetectableEffect: number // experimentsConfigLogic
isRunningTimeConfigModalOpen: boolean // modalsLogic
- automaticCalculation: RunningTimeTargetApi | null
- automaticCalculationInput: AutomaticTargetRequest | null
- automaticCalculationLoading: boolean
+ automaticTarget: RunningTimeTargetApi | null
+ automaticTargetFailed: boolean
+ automaticTargetLoading: boolean
+ automaticTargetRequest: AutomaticTargetRequest | null
+ canIncludeMoreUsers: boolean
config: RunningTimeConfig
configOverrides: Partial | null
currentExposures: number | null
dailyExposureRate: number | null
editedMde: number | null
+ enteredMde: number | null
+ estimateMetric: EstimateMetric | null
initialConfig: RunningTimeConfig
isCalculating: boolean
isComplete: boolean
+ isManualEstimate: boolean
isManualMode: boolean
isSaving: boolean
manualFormPreview: ManualPreview
@@ -89,7 +106,9 @@ export interface runningTimeLogicValues {
metricsWithResults: MetricWithResult[]
mode: RunningTimeConfig['mode']
numberOfVariants: number
+ outlook: RunningTimeOutlook | null
remainingDays: number | null
+ selectedCheckpointKey: string | null
targetEffect: number
targetSampleSize: number | null
}
@@ -105,22 +124,25 @@ export interface runningTimeLogicActions {
closeRunningTimeConfigModal: () => {
value: true
} // modalsLogic
+ openRunningTimeConfigModal: () => {
+ value: true
+ } // modalsLogic
cancel: () => {
value: true
}
- loadAutomaticCalculation: (_: AutomaticTargetRequest) => AutomaticTargetRequest
- loadAutomaticCalculationFailure: (
+ loadAutomaticTarget: (_: AutomaticTargetRequest) => AutomaticTargetRequest
+ loadAutomaticTargetFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
- loadAutomaticCalculationSuccess: (
- automaticCalculation: RunningTimeTargetApi | null,
+ loadAutomaticTargetSuccess: (
+ automaticTarget: RunningTimeTargetApi | null,
payload?: AutomaticTargetRequest
) => {
- automaticCalculation: RunningTimeTargetApi | null
+ automaticTarget: RunningTimeTargetApi | null
payload?: AutomaticTargetRequest
}
loadManualPreview: (input: RunningTimeCalculationInputApi) => RunningTimeCalculationInputApi
@@ -153,6 +175,13 @@ export interface runningTimeLogicActions {
save: () => {
value: true
}
+ selectCheckpoint: (
+ key: string,
+ effect: number
+ ) => {
+ effect: number
+ key: string
+ }
setConfig: (config: Partial) => {
config: Partial
}
@@ -178,23 +207,20 @@ export interface runningTimeLogicMeta {
) => RunningTimeConfig
mde: (config: RunningTimeConfig) => number
editedMde: (configOverrides: Partial | null) => number | null
- targetEffect: (
- editedMde: number | null,
- automaticCalculation: RunningTimeTargetApi | null,
- mde: number
- ) => number
+ targetEffect: (editedMde: number | null, automaticTarget: RunningTimeTargetApi | null, mde: number) => number
mode: (config: RunningTimeConfig) => RunningTimeConfig['mode']
metricsWithResults: (
experiment: Experiment,
primaryMetricsResults: CachedNewExperimentQueryResponse[],
primaryMetricsResultsErrors: unknown[]
) => MetricWithResult[]
+ isManualEstimate: (isManualMode: boolean, mode: 'automatic' | 'manual') => boolean
currentExposures: (
- isManualMode: boolean,
+ isManualEstimate: boolean,
metricsWithResults: MetricWithResult[],
- automaticCalculation: RunningTimeTargetApi | null
+ automaticTarget: RunningTimeTargetApi | null
) => number | null
- automaticCalculationInput: (
+ automaticTargetRequest: (
mode: 'automatic' | 'manual',
metricsWithResults: MetricWithResult[]
) => AutomaticTargetRequest | null
@@ -203,29 +229,37 @@ export interface runningTimeLogicMeta {
numberOfVariants: number
) => RunningTimeCalculationInputApi | null
targetSampleSize: (
- isManualMode: boolean,
+ isManualEstimate: boolean,
targetEffect: number,
experiment: Experiment,
- automaticCalculationInput: AutomaticTargetRequest | null,
- automaticCalculation: RunningTimeTargetApi | null
+ automaticTargetRequest: AutomaticTargetRequest | null,
+ automaticTarget: RunningTimeTargetApi | null
) => number | null
dailyExposureRate: (
- isManualMode: boolean,
+ isManualEstimate: boolean,
experiment: Experiment,
- currentExposures: number | null
+ automaticTarget: RunningTimeTargetApi | null
) => number | null
remainingDays: (
targetSampleSize: number | null,
currentExposures: number | null,
dailyExposureRate: number | null,
- experiment: Experiment
+ experiment: Experiment,
+ isManualEstimate: boolean,
+ outlook: RunningTimeOutlook | null
) => number | null
+ outlook: (
+ isManualEstimate: boolean,
+ automaticTarget: RunningTimeTargetApi | null,
+ targetEffect: number,
+ defaultMinimumDetectableEffect: number,
+ enteredMde: number | null,
+ selectedCheckpointKey: string | null
+ ) => RunningTimeOutlook | null
+ canIncludeMoreUsers: (experiment: Experiment) => boolean
+ estimateMetric: (experiment: Experiment, metricsWithResults: MetricWithResult[]) => EstimateMetric | null
isComplete: (currentExposures: number | null, targetSampleSize: number | null) => boolean
- isCalculating: (
- isManualMode: boolean,
- isRecalculating: boolean,
- automaticCalculationLoading: boolean
- ) => boolean
+ isCalculating: (isManualEstimate: boolean, isRecalculating: boolean, automaticTargetLoading: boolean) => boolean
manualFormPreview: (
manualPreviewInput: RunningTimeCalculationInputApi | null,
manualPreview: ManualPreview
@@ -262,13 +296,14 @@ export const runningTimeLogic = kea([
experimentLogic({ experimentId }),
['setExperiment', 'setUnmodifiedExperiment'],
modalsLogic,
- ['closeRunningTimeConfigModal'],
+ ['openRunningTimeConfigModal', 'closeRunningTimeConfigModal'],
],
}
}),
actions({
setConfig: (config: Partial) => ({ config }),
+ selectCheckpoint: (key: string, effect: number) => ({ key, effect }),
resetConfig: true,
save: true,
cancel: true,
@@ -281,6 +316,23 @@ export const runningTimeLogic = kea([
null as Partial | null,
{
setConfig: (state, { config }) => ({ ...state, ...config }),
+ selectCheckpoint: (state, { effect }) => ({ ...state, mde: effect }),
+ resetConfig: () => null,
+ },
+ ],
+ enteredMde: [
+ null as number | null,
+ {
+ setConfig: (state, { config }) => (config.mde === undefined ? state : config.mde),
+ resetConfig: () => null,
+ },
+ ],
+ // The row chosen with "Use". The slider and the input make the custom row the target again.
+ selectedCheckpointKey: [
+ null as string | null,
+ {
+ selectCheckpoint: (_, { key }) => key,
+ setConfig: (state, { config }) => (config.mde === undefined ? state : null),
resetConfig: () => null,
},
],
@@ -290,14 +342,22 @@ export const runningTimeLogic = kea([
setSaving: (_, { saving }) => saving,
},
],
+ automaticTargetFailed: [
+ false,
+ {
+ loadAutomaticTarget: () => false,
+ loadAutomaticTargetSuccess: () => false,
+ loadAutomaticTargetFailure: () => true,
+ },
+ ],
}),
loaders(({ values, props }) => ({
// Automatic mode: the target the backend sizes from the primary metric's stored result and the flag's split.
- automaticCalculation: [
+ automaticTarget: [
null as RunningTimeTargetApi | null,
{
- loadAutomaticCalculation: async (_: AutomaticTargetRequest, breakpoint) => {
+ loadAutomaticTarget: async (_: AutomaticTargetRequest, breakpoint) => {
const experimentId = props.experiment.id
if (typeof experimentId !== 'number') {
return null
@@ -380,12 +440,12 @@ export const runningTimeLogic = kea([
// The effect the automatic target is sized for: an edited one, else the one the backend sized for. The backend's
// can differ from `mde` before the team default loads, or when the saved effect isn't a positive number.
targetEffect: [
- (s) => [s.editedMde, s.automaticCalculation, s.mde],
- (editedMde: number | null, automaticCalculation: RunningTimeTargetApi | null, mde: number): number =>
- editedMde ?? automaticCalculation?.minimum_detectable_effect ?? mde,
+ (s) => [s.editedMde, s.automaticTarget, s.mde],
+ (editedMde: number | null, automaticTarget: RunningTimeTargetApi | null, mde: number): number =>
+ editedMde ?? automaticTarget?.minimum_detectable_effect ?? mde,
],
- // A primitive, so that automaticCalculationInput stays stable when the experiment object is replaced (e.g.
- // after persisting an estimate) but the mode is unchanged.
+ // A primitive, so that automaticTargetRequest stays stable when the experiment object is replaced (e.g. after
+ // persisting an estimate) but the mode is unchanged.
mode: [(s) => [s.config], (config: RunningTimeConfig): RunningTimeConfig['mode'] => config.mode],
// Primary metrics zipped with their results, so automatic mode always has a baseline to read.
@@ -398,10 +458,17 @@ export const runningTimeLogic = kea([
) => (experiment ? metricResults(experiment)(results, errors, 'primary') : []),
],
+ // Switching a manual experiment to automatic in the settings previews the automatic estimate, so the manual one
+ // applies only while both the saved and the edited mode are manual.
+ isManualEstimate: [
+ (s) => [s.isManualMode, s.mode],
+ (isManualMode: boolean, mode: RunningTimeConfig['mode']): boolean => isManualMode && mode === 'manual',
+ ],
+
currentExposures: [
- (s) => [s.isManualMode, s.metricsWithResults, s.automaticCalculation],
+ (s) => [s.isManualEstimate, s.metricsWithResults, s.automaticTarget],
(
- isManualMode: boolean,
+ isManualEstimate: boolean,
results: {
displayIndex: number
error: any
@@ -409,18 +476,18 @@ export const runningTimeLogic = kea([
metricIndex: number
result: any
}[],
- automaticCalculation: RunningTimeTargetApi | null
+ automaticTarget: RunningTimeTargetApi | null
): number | null => {
// The backend counts the exposures the automatic target sizes, so a variant at 0% does not count.
- if (!isManualMode && automaticCalculation?.current_sample_size != null) {
- return automaticCalculation.current_sample_size
+ if (!isManualEstimate && automaticTarget?.current_sample_size != null) {
+ return automaticTarget.current_sample_size
}
return calculateCurrentExposures(results?.[0]?.result ?? null)
},
],
// Request for the automatic-mode target, or null when not applicable (manual mode / no results yet).
- automaticCalculationInput: [
+ automaticTargetRequest: [
(s) => [s.mode, s.metricsWithResults],
(
mode: RunningTimeConfig['mode'],
@@ -472,59 +539,82 @@ export const runningTimeLogic = kea([
],
targetSampleSize: [
- (s) => [s.isManualMode, s.targetEffect, s.experiment, s.automaticCalculationInput, s.automaticCalculation],
+ (s) => [s.isManualEstimate, s.targetEffect, s.experiment, s.automaticTargetRequest, s.automaticTarget],
(
- isManualMode: boolean,
+ isManualEstimate: boolean,
targetEffect: number,
experiment: Experiment,
automaticInput: AutomaticTargetRequest | null,
- automaticCalculation: RunningTimeTargetApi | null
+ automaticTarget: RunningTimeTargetApi | null
): number | null => {
- if (isManualMode) {
+ if (isManualEstimate) {
// Persisted by the save listener from the same backend calculation.
return experiment?.running_time_calculation?.recommended_sample_size ?? null
}
- if (!automaticInput || !automaticCalculation?.recommended_sample_size) {
+ if (!automaticInput || !automaticTarget?.recommended_sample_size) {
return null
}
return rescaleSampleSize(
- automaticCalculation.recommended_sample_size,
- automaticCalculation.minimum_detectable_effect,
+ automaticTarget.recommended_sample_size,
+ automaticTarget.minimum_detectable_effect,
targetEffect
)
},
],
dailyExposureRate: [
- (s) => [s.isManualMode, s.experiment, s.currentExposures],
- (isManualMode: boolean, experiment: Experiment, currentExposures: number | null): number | null => {
- if (isManualMode) {
+ (s) => [s.isManualEstimate, s.experiment, s.automaticTarget],
+ (
+ isManualEstimate: boolean,
+ experiment: Experiment,
+ automaticTarget: RunningTimeTargetApi | null
+ ): number | null => {
+ if (isManualEstimate) {
return experiment?.running_time_calculation?.exposure_estimate_config?.manualExposureRate ?? null
}
-
- // Automatic mode: calculate from actual rate
- const daysElapsed = calculateDaysElapsed(experiment?.start_date ?? null)
- return calculateExposureRate(currentExposures, daysElapsed)
+ return automaticTarget?.outlook?.exposures_per_day ?? null
},
],
// Days until we reach target sample size
remainingDays: [
- (s) => [s.targetSampleSize, s.currentExposures, s.dailyExposureRate, s.experiment],
+ (s) => [
+ s.targetSampleSize,
+ s.currentExposures,
+ s.dailyExposureRate,
+ s.experiment,
+ s.isManualEstimate,
+ s.outlook,
+ ],
(
target: number | null,
current: number | null,
rate: number | null,
- experiment: Experiment
+ experiment: Experiment,
+ isManualEstimate: boolean,
+ outlook: RunningTimeOutlook | null
): number | null => {
if (!target || target <= 0 || !rate || rate <= 0) {
return null
}
+ // The automatic rate comes from the backend's outlook, which projects from the first 0.1 days on.
+ if (!isManualEstimate) {
+ if (current !== null && current >= target) {
+ return 0
+ }
+ if (!experiment?.start_date) {
+ return Math.ceil(target / rate)
+ }
+ // The projection reaches the target `target / rate` days after launch, or at the checkpoint whose
+ // effect the target took. Calendar days to that date keep the header, the table's date and its
+ // "in N days" in step, also when the results end before today.
+ return daysFromToday(experiment.start_date, outlook?.daysToTarget ?? target / rate)
+ }
- // Need minimum data for automatic mode
+ // The manual estimate counts down from the exposures so far once there are a day and 100 of them.
const daysElapsed = calculateDaysElapsed(experiment?.start_date ?? null)
if (!daysElapsed || daysElapsed < 1 || !current || current < 100) {
- // Not enough data yet - show total estimated time if we have rate
+ // Before that, the whole planned run.
if (rate > 0) {
return Math.ceil(target / rate)
}
@@ -539,6 +629,65 @@ export const runningTimeLogic = kea([
return Math.ceil(remaining / rate)
},
],
+ // What the experiment can detect over time, for the automatic estimate.
+ outlook: [
+ (s) => [
+ s.isManualEstimate,
+ s.automaticTarget,
+ s.targetEffect,
+ s.defaultMinimumDetectableEffect,
+ s.enteredMde,
+ s.selectedCheckpointKey,
+ ],
+ (
+ isManualEstimate: boolean,
+ automaticTarget: RunningTimeTargetApi | null,
+ targetEffect: number,
+ defaultMinimumDetectableEffect: number,
+ enteredMde: number | null,
+ selectedCheckpointKey: string | null
+ ): RunningTimeOutlook | null =>
+ isManualEstimate
+ ? null
+ : buildRunningTimeOutlook({
+ projection: automaticTarget?.outlook ?? null,
+ currentSampleSize: automaticTarget?.current_sample_size ?? null,
+ savedEffect: automaticTarget?.minimum_detectable_effect ?? null,
+ savedSampleSize: automaticTarget?.recommended_sample_size ?? null,
+ defaultEffect: defaultMinimumDetectableEffect,
+ enteredEffect: enteredMde,
+ selectedKey: selectedCheckpointKey,
+ minimumDetectableEffect: targetEffect,
+ }),
+ ],
+ canIncludeMoreUsers: [
+ (s) => [s.experiment],
+ (experiment: Experiment): boolean => {
+ // A group without conditions at 100% already includes every user.
+ const includesEveryone = (experiment?.feature_flag?.filters?.groups ?? []).some(
+ (group) => !group.properties?.length && (group.rollout_percentage ?? 100) >= 100
+ )
+ return !includesEveryone && !isExperimentExposureFrozen(experiment)
+ },
+ ],
+ // The first primary metric in display order, which is the one the backend sizes the target from.
+ estimateMetric: [
+ (s) => [s.experiment, s.metricsWithResults],
+ (experiment: Experiment, metricsWithResults: MetricWithResult[]): EstimateMetric | null => {
+ const metric = metricsWithResults?.[0]?.metric
+ if (!metric) {
+ return null
+ }
+ const secondaryCount =
+ (experiment?.metrics_secondary?.length ?? 0) +
+ (experiment?.saved_metrics ?? []).filter((saved) => saved.metadata?.type === 'secondary').length
+ return {
+ name: metric.name || getDefaultMetricTitle(metric),
+ isFirstOfSeveral: metricsWithResults.length > 1,
+ hasOtherMetrics: metricsWithResults.length > 1 || secondaryCount > 0,
+ }
+ },
+ ],
isComplete: [
(s) => [s.currentExposures, s.targetSampleSize],
(current: number | null, target: number | null): boolean =>
@@ -549,12 +698,12 @@ export const runningTimeLogic = kea([
// instead of flashing a pending state during the gap between the two. Manual mode has no async
// estimate to wait on, so it never counts as calculating.
isCalculating: [
- (s) => [s.isManualMode, s.isRecalculating, s.automaticCalculationLoading],
- (isManualMode: boolean, isRecalculating: boolean, automaticCalculationLoading: boolean): boolean => {
- if (isManualMode) {
+ (s) => [s.isManualEstimate, s.isRecalculating, s.automaticTargetLoading],
+ (isManualEstimate: boolean, isRecalculating: boolean, automaticTargetLoading: boolean): boolean => {
+ if (isManualEstimate) {
return false
}
- return isRecalculating || automaticCalculationLoading
+ return isRecalculating || automaticTargetLoading
},
],
manualFormPreview: [
@@ -567,10 +716,10 @@ export const runningTimeLogic = kea([
subscriptions(({ actions }) => ({
// kea-subscriptions fires on reference change. The recalc poll replaces the metric-results array every
// tick, so these inputs get a fresh object reference even when their values are identical, which would
- // re-fire the calculation POST on every poll. Skip the dispatch unless the value actually changed.
- automaticCalculationInput: (input: AutomaticTargetRequest | null, oldInput: AutomaticTargetRequest | null) => {
+ // re-fire the request on every poll. Skip the dispatch unless the value actually changed.
+ automaticTargetRequest: (input: AutomaticTargetRequest | null, oldInput: AutomaticTargetRequest | null) => {
if (input && !objectsEqual(input, oldInput)) {
- actions.loadAutomaticCalculation(input)
+ actions.loadAutomaticTarget(input)
}
},
manualPreviewInput: (
@@ -585,7 +734,7 @@ export const runningTimeLogic = kea([
listeners(({ actions, values, props }) => ({
// Persist the automatic estimate once the backend calculation lands and the selectors are fresh.
- loadAutomaticCalculationSuccess: () => {
+ loadAutomaticTargetSuccess: () => {
actions.persistRunningTimeEstimate()
},
@@ -659,6 +808,8 @@ export const runningTimeLogic = kea([
save: async () => {
const { config, numberOfVariants, experiment, currentProjectId } = values
+ // Read before the save, which replaces the experiment and with it the initial config.
+ const previousMde = values.initialConfig.mde
actions.setSaving(true)
let saved = false
@@ -729,15 +880,33 @@ export const runningTimeLogic = kea([
if (!saved) {
return
}
+ // pinned: analytics event name and properties.
+ posthog.capture('experiment running time settings saved', {
+ experiment_id: props.experiment.id,
+ mode: config.mode,
+ minimum_detectable_effect: config.mde,
+ previous_minimum_detectable_effect: previousMde,
+ })
actions.closeRunningTimeConfigModal()
actions.resetConfig()
// The target on hand is sized for the previous effect. The backend sizes it for the saved one, and its
// success persists the estimate.
- if (config.mode === 'automatic' && values.automaticCalculationInput) {
- actions.loadAutomaticCalculation(values.automaticCalculationInput)
+ if (config.mode === 'automatic' && values.automaticTargetRequest) {
+ actions.loadAutomaticTarget(values.automaticTargetRequest)
}
},
+ openRunningTimeConfigModal: () => {
+ const { config, outlook } = values
+ // pinned: analytics event name and properties.
+ posthog.capture('experiment running time settings opened', {
+ experiment_id: props.experiment.id,
+ mode: config.mode,
+ out_of_reach: outlook?.isOutOfReach ?? null,
+ days_to_target: outlook ? Math.round(outlook.daysToTarget) : null,
+ })
+ },
+
cancel: () => {
actions.closeRunningTimeConfigModal()
actions.resetConfig()
diff --git a/frontend/src/scenes/experiments/stories/ExperimentRunningTimeOutlook.stories.tsx b/frontend/src/scenes/experiments/stories/ExperimentRunningTimeOutlook.stories.tsx
new file mode 100644
index 000000000000..43d93bb430e5
--- /dev/null
+++ b/frontend/src/scenes/experiments/stories/ExperimentRunningTimeOutlook.stories.tsx
@@ -0,0 +1,126 @@
+import { Meta, StoryObj } from '@storybook/react'
+import { waitFor } from '@testing-library/dom'
+import userEvent from '@testing-library/user-event'
+
+import { App } from 'scenes/App'
+import { urls } from 'scenes/urls'
+
+import { mswDecorator } from '~/mocks/browser'
+import EXPERIMENT_WITH_MEAN_METRIC from '~/mocks/fixtures/api/experiments/experiment_with_mean_metric.json'
+import EXPOSURE_QUERY_RESULT from '~/mocks/fixtures/api/experiments/exposure_query_result.json'
+import MEAN_METRIC_RESULT from '~/mocks/fixtures/api/experiments/mean_metric_result.json'
+import { NodeKind } from '~/queries/schema/schema-general'
+
+import type { RunningTimeTargetApi } from 'products/experiments/frontend/generated/api.schemas'
+
+import { recalculationMocks, resultsByMetricType } from './recalculationMocks'
+
+const EXPERIMENT_PATH = `/api/projects/:team_id/experiments/${EXPERIMENT_WITH_MEAN_METRIC.id}/`
+const RECALCULATION = recalculationMocks(
+ EXPERIMENT_WITH_MEAN_METRIC,
+ resultsByMetricType(EXPERIMENT_WITH_MEAN_METRIC, { mean: MEAN_METRIC_RESULT })
+)
+
+// The fixture launched on 2025-05-20, so `mockDate` puts the run two weeks in, as each target response's outlook says.
+const targetMocks = (target: RunningTimeTargetApi): Parameters[0] => ({
+ get: {
+ ...RECALCULATION.get,
+ [EXPERIMENT_PATH]: EXPERIMENT_WITH_MEAN_METRIC,
+ [`${EXPERIMENT_PATH}running_time_target/`]: target,
+ '/api/projects/:team_id/experiment_holdouts': [],
+ '/api/projects/:team_id/experiment_saved_metrics/': [],
+ [`/api/projects/:team_id/feature_flags/${EXPERIMENT_WITH_MEAN_METRIC.feature_flag.id}/`]:
+ EXPERIMENT_WITH_MEAN_METRIC.feature_flag,
+ [`/api/projects/:team_id/feature_flags/${EXPERIMENT_WITH_MEAN_METRIC.feature_flag.id}/status/`]: {},
+ '/api/environments/:team_id/default_release_conditions/': [],
+ '/api/projects/:team_id/experiments_config/': {},
+ [`${EXPERIMENT_PATH}health/`]: { findings: [], checks: [] },
+ '/api/projects/:team_id/property_definitions/seen_together': {},
+ },
+ post: {
+ ...RECALCULATION.post,
+ '/api/environments/:team_id/query/:kind': async ({ request }) => {
+ const body = (await request.json()) as Record
+ return body.query.kind === NodeKind.ExperimentExposureQuery
+ ? [200, EXPOSURE_QUERY_RESULT]
+ : [200, { results: [] }]
+ },
+ },
+ patch: { [EXPERIMENT_PATH]: EXPERIMENT_WITH_MEAN_METRIC },
+})
+
+const openRunningTimeSettings = async (): Promise => {
+ const settings = await waitFor(() => {
+ const button = document.querySelector('[data-attr="experiment-running-time-config"]')
+ if (!button) {
+ throw new Error('The running time settings button has not rendered yet')
+ }
+ return button
+ })
+ await userEvent.click(settings)
+}
+
+const meta: Meta = {
+ component: App,
+ title: 'Scenes-App/Experiments',
+ parameters: {
+ layout: 'fullscreen',
+ viewMode: 'story',
+ mockDate: '2025-06-03',
+ pageUrl: urls.experiment(EXPERIMENT_WITH_MEAN_METRIC.id),
+ },
+}
+export default meta
+
+type Story = StoryObj<{}>
+
+// A target days away: the settings place it between the upcoming weeks.
+export const ExperimentRunningTimeOnTrack: Story = {
+ decorators: [
+ mswDecorator(
+ targetMocks({
+ minimum_detectable_effect: 30,
+ recommended_sample_size: 3000,
+ current_sample_size: 2000,
+ outlook: {
+ days_since_launch: 14,
+ exposures_per_day: 2000 / 14,
+ detectable_effect_now: 36.7,
+ upcoming_checkpoints: [
+ { weeks_since_launch: 4, detectable_effect: 26 },
+ { weeks_since_launch: 6, detectable_effect: 21.2 },
+ { weeks_since_launch: 8, detectable_effect: 18.4 },
+ ],
+ reachable_effect: 21.2,
+ },
+ })
+ ),
+ ],
+ play: openRunningTimeSettings,
+}
+
+// A target months away: the header says what the experiment can detect by week 4, and the settings list the ways to
+// finish sooner.
+export const ExperimentRunningTimeOutOfReach: Story = {
+ decorators: [
+ mswDecorator(
+ targetMocks({
+ minimum_detectable_effect: 30,
+ recommended_sample_size: 35556,
+ current_sample_size: 2000,
+ outlook: {
+ days_since_launch: 14,
+ exposures_per_day: 2000 / 14,
+ detectable_effect_now: 126.5,
+ upcoming_checkpoints: [
+ { weeks_since_launch: 4, detectable_effect: 89.4 },
+ { weeks_since_launch: 6, detectable_effect: 73 },
+ { weeks_since_launch: 8, detectable_effect: 63.2 },
+ ],
+ reachable_effect: 73,
+ },
+ })
+ ),
+ ],
+ play: openRunningTimeSettings,
+}
diff --git a/frontend/src/scenes/project-homepage/today/Today.stories.tsx b/frontend/src/scenes/project-homepage/today/Today.stories.tsx
index 9ccd93135209..980e0d8771a7 100644
--- a/frontend/src/scenes/project-homepage/today/Today.stories.tsx
+++ b/frontend/src/scenes/project-homepage/today/Today.stories.tsx
@@ -912,6 +912,7 @@ const meta: Meta = {
const results = type ? LIBRARY.filter((entry) => entry.type === type) : LIBRARY
return [200, { results, count: results.length }]
},
+ '/api/projects/:team_id/file_system/frequent/': [],
'/api/environments/:team_id/file_system/unfiled/': { results: [], count: 0 },
'/api/projects/:team_id/canvases/canvas-building/': VIEW_CANVASES[1],
'/api/projects/:team_id/tasks/task-canvas-building/': {
@@ -972,6 +973,52 @@ type Story = StoryObj<{}>
export const Home: Story = { decorators: [keepNumbers([])] }
+export const HomeWithFrequentItems: Story = {
+ parameters: {
+ featureFlags: {
+ [FEATURE_FLAGS.TODAY_RAIL_NAV]: true,
+ [FEATURE_FLAGS.POSTHOG_CODE_TASK_ANALYSIS]: true,
+ [FEATURE_FLAGS.TODAY_JUMP_BACK_RANKING]: 'test',
+ },
+ },
+ decorators: [
+ keepNumbers([]),
+ mswDecorator({
+ get: {
+ '/api/projects/:team_id/file_system/frequent/': [
+ {
+ id: 'fs-frequent-1',
+ path: 'Dashboards/Daily operations',
+ type: 'dashboard',
+ ref: '81',
+ href: '/dashboard/81',
+ last_viewed_at: '2026-09-27T09:00:00Z',
+ },
+ {
+ id: 'fs-frequent-2',
+ path: 'Notebooks/Weekly review',
+ type: 'notebook',
+ ref: 'weekly-review',
+ href: '/notebooks/weekly-review',
+ last_viewed_at: '2026-09-21T09:00:00Z',
+ },
+ ],
+ },
+ }),
+ ],
+}
+
+export const HomeWithRecentRanking: Story = {
+ ...HomeWithFrequentItems,
+ parameters: {
+ featureFlags: {
+ [FEATURE_FLAGS.TODAY_RAIL_NAV]: true,
+ [FEATURE_FLAGS.POSTHOG_CODE_TASK_ANALYSIS]: true,
+ [FEATURE_FLAGS.TODAY_JUMP_BACK_RANKING]: 'control',
+ },
+ },
+}
+
export const HomeWithKeptNumbers: Story = {
decorators: [
keepNumbers([
diff --git a/frontend/src/scenes/project-homepage/today/TodayRecents.tsx b/frontend/src/scenes/project-homepage/today/TodayRecents.tsx
index c69438d05048..88832d47ab82 100644
--- a/frontend/src/scenes/project-homepage/today/TodayRecents.tsx
+++ b/frontend/src/scenes/project-homepage/today/TodayRecents.tsx
@@ -4,8 +4,8 @@ import { useId, useMemo } from 'react'
import { IconChevronDown } from '@posthog/icons'
+import { TZLabel } from 'lib/components/TZLabel'
import { LinkPrimitive } from 'lib/lemon-ui/Link'
-import { capitalizeFirstLetter } from 'lib/utils/strings'
import { todayObjectGlanceLogic } from '~/layout/today/todayObjectGlanceLogic'
import { TodayObjectIcon } from '~/layout/today/TodayObjectIcon'
@@ -18,8 +18,17 @@ import { FileSystemEntry } from '~/queries/schema/schema-general'
import { TodayHomeHeading } from './TodayHomeHeading'
import { RECENT_OBJECT_COUNT, todayLogic } from './todayLogic'
-function RecentObjectRow({ entry }: { entry: FileSystemEntry }): JSX.Element {
+function RecentObjectRow({
+ entry,
+ position,
+ frequent,
+}: {
+ entry: FileSystemEntry
+ position: number
+ frequent: boolean
+}): JSX.Element {
const preview = useMemo(() => objectPreview(entry), [entry])
+ const { jumpBackOpened } = useActions(todayLogic)
return (
@@ -28,12 +37,20 @@ function RecentObjectRow({ entry }: { entry: FileSystemEntry }): JSX.Element {
to={preview.href ?? undefined}
className="TodayJumpRow__link"
data-attr="today-home-recent-object"
+ onClick={() => jumpBackOpened(entry, position, frequent)}
>
{preview.name}
- {preview.typeLabel && (
- {capitalizeFirstLetter(preview.typeLabel)}
- )}
+
+ {entry.last_viewed_at ? (
+
+ Last opened
+
+
+ ) : (
+ Time unknown
+ )}
+
)
@@ -41,7 +58,8 @@ function RecentObjectRow({ entry }: { entry: FileSystemEntry }): JSX.Element {
export function TodayRecents(): JSX.Element {
useMountedLogic(todayObjectGlanceLogic)
- const { recentObjects, recentsHasLoaded, listExpanded } = useValues(todayLogic)
+ const { recentObjects, frequentObjectsLoading, recentsHasLoaded, listExpanded, useFrequentJumpBack } =
+ useValues(todayLogic)
const { setListExpanded } = useActions(todayLogic)
const { pickPane } = useActions(todayShellLogic)
const titleId = useId()
@@ -63,8 +81,8 @@ export function TodayRecents(): JSX.Element {
)}
- {!recentsHasLoaded ? (
- Finding what you looked at last…
+ {!recentsHasLoaded || (useFrequentJumpBack && frequentObjectsLoading) ? (
+ Finding your items…
) : recentObjects.length === 0 ? (
@@ -84,15 +102,25 @@ export function TodayRecents(): JSX.Element {
) : (
- {shown.map((entry) => (
-
+ {shown.map((entry, index) => (
+
))}
{more.length > 0 && (
- {more.map((entry) => (
-
+ {more.map((entry, index) => (
+
))}
diff --git a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts
index 746c0947f658..0708699fb908 100644
--- a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts
+++ b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts
@@ -2,7 +2,11 @@ import { router } from 'kea-router'
import { expectLogic } from 'kea-test-utils'
import posthog from 'posthog-js'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+
import { useMocks } from '~/mocks/jest'
+import { recentItemsModel } from '~/models/recentItemsModel'
import { initKeaTests } from '~/test/init'
import { AccessControlLevel, AccessControlResourceType } from '~/types'
@@ -18,6 +22,7 @@ import { makeHighlightItem, makeHighlights } from './todayTestFixtures'
import { todayVerdictLogic } from './todayVerdictLogic'
describe('todayLogic', () => {
+ let frequentResponse: [number, unknown]
let listResponse: [number, any]
let listParams: URLSearchParams | null
let reportResponse: [number, any] | null
@@ -25,6 +30,7 @@ describe('todayLogic', () => {
let highlightCalls: number
beforeEach(() => {
+ frequentResponse = [200, []]
highlightResponses = [[200, makeHighlights()]]
listResponse = [200, { results: [], count: 0 }]
highlightCalls = 0
@@ -32,6 +38,7 @@ describe('todayLogic', () => {
reportResponse = null
useMocks({
get: {
+ '/api/projects/:team_id/file_system/frequent/': () => frequentResponse,
'/api/projects/:team_id/signals/reports/': ({ request }) => {
listParams = new URL(request.url).searchParams
return listResponse
@@ -49,10 +56,65 @@ describe('todayLogic', () => {
initKeaTests()
})
+ it.each([
+ { status: 200, variant: 'test' },
+ { status: 500, variant: 'test' },
+ { status: 200, variant: 'control' },
+ { status: 200, variant: false },
+ ])('selects $variant ranking when the request returns $status', async ({ status, variant }) => {
+ const entry = (id: string): { id: string; path: string; type: string; ref: string } => ({
+ id,
+ path: `Dashboards/${id}`,
+ type: 'dashboard',
+ ref: id,
+ })
+ frequentResponse = [status, status === 200 ? [entry('daily'), entry('weekly')] : {}]
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.TODAY_JUMP_BACK_RANKING]: variant })
+ const logic = todayLogic()
+ logic.mount()
+ await expectLogic(logic).toFinishAllListeners()
+ recentItemsModel.actions.loadRecentsSuccess(['a', 'b', 'c', 'd'].map(entry))
+ const mixed = status === 200 && variant === 'test'
+ expect(logic.values.recentObjects.map((item) => item.id)).toEqual(
+ mixed ? ['a', 'b', 'daily', 'weekly', 'c', 'd'] : ['a', 'b', 'c', 'd']
+ )
+ logic.actions.jumpBackOpened(logic.values.recentObjects[2], 3, mixed)
+ expect(posthog.capture).toHaveBeenCalledWith(
+ 'today jump back opened',
+ expect.objectContaining({
+ position: 3,
+ reason: mixed ? 'frequent' : 'recent',
+ ranking_variant: variant === 'test' ? 'test' : 'control',
+ })
+ )
+ })
+
afterEach(() => {
jest.useRealTimers()
})
+ it.each(['toggle', 'url'])('reloads frequent items when sample mode is disabled by %s', async (source) => {
+ const entry = { id: 'daily', path: 'Daily dashboard', type: 'dashboard', ref: 'daily' }
+ frequentResponse = [200, [entry]]
+ const logic = todayLogic()
+ logic.mount()
+ await expectLogic(logic).toFinishAllListeners()
+
+ await expectLogic(logic, () => logic.actions.setUseSampleData(true))
+ .toFinishAllListeners()
+ .toMatchValues({ frequentObjects: [] })
+
+ await expectLogic(logic, () => {
+ if (source === 'url') {
+ router.actions.push('/home', { sample: '0' })
+ } else {
+ logic.actions.setUseSampleData(false)
+ }
+ })
+ .toFinishAllListeners()
+ .toMatchValues({ useSampleData: false, frequentObjects: [entry] })
+ })
+
it.each([
{
shown: 'the report list',
diff --git a/frontend/src/scenes/project-homepage/today/todayLogic.ts b/frontend/src/scenes/project-homepage/today/todayLogic.ts
index 3394fdfcb8ea..b2cce3b58e2b 100644
--- a/frontend/src/scenes/project-homepage/today/todayLogic.ts
+++ b/frontend/src/scenes/project-homepage/today/todayLogic.ts
@@ -5,14 +5,19 @@ import type { LocationChangedPayload } from 'kea-router/lib/types'
import posthog from 'posthog-js'
import { ApiError } from 'lib/api'
+import { FEATURE_FLAGS } from 'lib/constants'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
+import { FeatureFlagsSet, featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { getProductAccessDisabledReason } from 'lib/utils/accessControlUtils'
+import { withTimeout } from 'lib/utils/async'
+import { isObject } from 'lib/utils/guards'
import { removeProjectIdIfPresent } from 'lib/utils/kea-router'
import { objectsEqual } from 'lib/utils/objects'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
+import { fileSystemFrequentList } from '~/generated/core/api'
import { recentItemsModel } from '~/models/recentItemsModel'
import { FileSystemEntry } from '~/queries/schema/schema-general'
import { TeamPublicType, TeamType, UserType } from '~/types'
@@ -40,7 +45,7 @@ import {
type TodayForYouRows,
} from './todayForYouRows'
import { todayHighlightsLogic } from './todayHighlightsLogic'
-import { recentObjectsForHome } from './todayRecentObjects'
+import { JumpBackObject, jumpBackObjectsForHome, recentObjectsForHome } from './todayRecentObjects'
import { type TodayReportPreview, reportPreview } from './todayReportPreviews'
import type { TodayReportState } from './todayReportState'
import { isSampleReportId, parseSampleParam, sampleTopReports } from './todaySampleReports'
@@ -148,6 +153,7 @@ interface TodayRankedReport {
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface todayLogicValues {
+ featureFlags: FeatureFlagsSet // featureFlagLogic
recents: FileSystemEntry[] // recentItemsModel
recentsHasLoaded: boolean // recentItemsModel
currentProjectId: number | string // teamLogic
@@ -173,6 +179,8 @@ export interface todayLogicValues {
forYouReady: boolean
forYouRows: TodayForYouRows
forYouVisibleRows: TodayForYouRow[]
+ frequentObjects: FileSystemEntry[]
+ frequentObjectsLoading: boolean
greeting: string
homeScrollTop: number | null
hour: number
@@ -184,7 +192,7 @@ export interface todayLogicValues {
moreReportsInInbox: number
moreReportsLoading: boolean
now: number
- recentObjects: FileSystemEntry[]
+ recentObjects: JumpBackObject[]
reportId: string | null
reportOpenContext: TodayReportOpenContext | null
reportPreviews: Record
@@ -195,6 +203,7 @@ export interface todayLogicValues {
shownReportIds: string[]
topReports: TodayReports | null
topReportsLoading: boolean
+ useFrequentJumpBack: boolean
useSampleData: boolean
}
@@ -221,6 +230,13 @@ export interface todayLogicActions {
searchParams: Record
url: string
} // router
+ loadCurrentTeamSuccess: (
+ currentTeam: TeamPublicType | null,
+ payload?: any
+ ) => {
+ currentTeam: TeamPublicType | null
+ payload?: any
+ } // teamLogic
loadHighlights: () => any // todayHighlightsLogic
askAi: (
prompt: string,
@@ -241,6 +257,30 @@ export interface todayLogicActions {
position: number
product: string
}
+ jumpBackOpened: (
+ entry: FileSystemEntry,
+ position: number,
+ frequent: boolean
+ ) => {
+ entry: FileSystemEntry
+ frequent: boolean
+ position: number
+ }
+ loadFrequentObjects: () => any
+ loadFrequentObjectsFailure: (
+ error: string,
+ errorObject?: any
+ ) => {
+ error: string
+ errorObject?: any
+ }
+ loadFrequentObjectsSuccess: (
+ frequentObjects: FileSystemEntry[],
+ payload?: any
+ ) => {
+ frequentObjects: FileSystemEntry[]
+ payload?: any
+ }
loadMoreReports: () => any
loadMoreReportsFailure: (
error: string,
@@ -323,7 +363,12 @@ export interface todayLogicMeta {
moreReportCount: (topReports: TodayReports | null) => number
hour: (now: number) => number
greeting: (hour: number, user: UserType | null) => string
- recentObjects: (recents: FileSystemEntry[]) => FileSystemEntry[]
+ useFrequentJumpBack: (featureFlags: FeatureFlagsSet) => boolean
+ recentObjects: (
+ recents: FileSystemEntry[],
+ frequentObjects: FileSystemEntry[],
+ useFrequentJumpBack: boolean
+ ) => JumpBackObject[]
shownReportIds: (reports: SignalReport[]) => string[]
currentMoreReports: (
moreReports: TodayMoreReports | null,
@@ -376,6 +421,8 @@ export const todayLogic = kea([
path(['scenes', 'project-homepage', 'today', 'todayLogic']),
connect(() => ({
values: [
+ featureFlagLogic,
+ ['featureFlags'],
userLogic,
['user'],
teamLogic,
@@ -395,7 +442,14 @@ export const todayLogic = kea([
todayVerdictLogic,
['reportStateOverrides'],
],
- actions: [router, ['locationChanged'], todayHighlightsLogic, ['loadHighlights']],
+ actions: [
+ router,
+ ['locationChanged'],
+ todayHighlightsLogic,
+ ['loadHighlights'],
+ teamLogic,
+ ['loadCurrentTeamSuccess'],
+ ],
})),
actions({
// `report` is the report the person reads. Without it, the context is the report list.
@@ -408,12 +462,52 @@ export const todayLogic = kea([
setReturnFocusReportId: (reportId: string | null) => ({ reportId }),
setReportOpenContext: (context: TodayReportOpenContext | null) => ({ context }),
setListExpanded: (list: TodayList, expanded: boolean) => ({ list, expanded }),
+ jumpBackOpened: (entry: FileSystemEntry, position: number, frequent: boolean) => ({
+ entry,
+ position,
+ frequent,
+ }),
showMoreForYou: true,
retryFailedLoads: true,
setUseSampleData: (useSampleData: boolean) => ({ useSampleData }),
tick: true,
}),
loaders(({ values }) => ({
+ frequentObjects: [
+ [] as FileSystemEntry[],
+ {
+ loadFrequentObjects: async (): Promise => {
+ if (values.useSampleData || values.currentProjectId === null) {
+ return []
+ }
+ try {
+ const projectId = values.currentProjectId
+ const entries = await withTimeout(
+ (signal) => fileSystemFrequentList(String(projectId), { limit: 8 }, { signal }),
+ 10_000,
+ 'loadFrequentObjects timed out'
+ )
+ if (values.currentProjectId !== projectId) {
+ return []
+ }
+ return entries.map((entry) => ({
+ id: entry.id,
+ path: entry.path,
+ type: entry.type,
+ ref: entry.ref ?? undefined,
+ href: entry.href ?? undefined,
+ meta: isObject(entry.meta) ? entry.meta : undefined,
+ last_viewed_at: entry.last_viewed_at,
+ }))
+ } catch (error) {
+ if (!(error instanceof ApiError && error.status === 403)) {
+ posthog.captureException(error)
+ }
+ return []
+ }
+ },
+ },
+ ],
topReports: [
null as TodayReports | null,
{
@@ -543,9 +637,23 @@ export const todayLogic = kea([
(s) => [s.hour, s.user],
(hour: number, user: UserType | null): string => greetingForHour(hour, user?.first_name),
],
+ useFrequentJumpBack: [
+ (s) => [s.featureFlags],
+ (featureFlags: FeatureFlagsSet): boolean => featureFlags[FEATURE_FLAGS.TODAY_JUMP_BACK_RANKING] === 'test',
+ ],
recentObjects: [
- (s) => [s.recents],
- (recents: FileSystemEntry[]): FileSystemEntry[] => recentObjectsForHome(recents, RECENT_OBJECT_MORE_COUNT),
+ (s) => [s.recents, s.frequentObjects, s.useFrequentJumpBack],
+ (
+ recents: FileSystemEntry[],
+ frequentObjects: FileSystemEntry[],
+ useFrequentJumpBack: boolean
+ ): JumpBackObject[] =>
+ useFrequentJumpBack
+ ? jumpBackObjectsForHome(recents, frequentObjects, RECENT_OBJECT_MORE_COUNT)
+ : recentObjectsForHome(recents, RECENT_OBJECT_MORE_COUNT).map((entry) => ({
+ ...entry,
+ jumpBackReason: 'recent',
+ })),
],
shownReportIds: [(s) => [s.reports], (reports: SignalReport[]): string[] => reports.map((report) => report.id)],
currentMoreReports: [
@@ -716,6 +824,18 @@ export const todayLogic = kea([
})
}
return {
+ loadCurrentTeamSuccess: () => actions.loadFrequentObjects(),
+ jumpBackOpened: ({ entry, position, frequent }) => {
+ if (!values.useSampleData) {
+ posthog.capture('today jump back opened', {
+ object_type: entry.type,
+ object_ref: entry.ref ?? entry.id,
+ position,
+ reason: frequent ? 'frequent' : 'recent',
+ ranking_variant: values.useFrequentJumpBack ? 'test' : 'control',
+ })
+ }
+ },
askAi: async ({ prompt, source, report }, breakpoint) => {
let context: TodayAskContext
// Sample reports have ids that do not exist, so PostHog AI gets no context to look up. A real
@@ -745,6 +865,7 @@ export const todayLogic = kea([
},
setUseSampleData: () => {
actions.loadTopReports()
+ actions.loadFrequentObjects()
},
exploreOpened: ({ product, position }) => {
posthog.capture('today explore opened', { product, position })
@@ -809,6 +930,7 @@ export const todayLogic = kea([
posthog.capture('today home loaded', {
report_count: topReports.results.length,
more_report_count: values.moreReportCount,
+ jump_back_ranking: values.useFrequentJumpBack ? 'recent-and-frequent-v1' : 'recent-v1',
})
},
loadMoreReports: () => {
@@ -858,6 +980,7 @@ export const todayLogic = kea([
} else {
actions.loadTopReports()
}
+ actions.loadFrequentObjects()
cache.disposables.add(() => {
const clock = window.setInterval(() => actions.tick(), CLOCK_MS)
return () => clearInterval(clock)
diff --git a/frontend/src/scenes/project-homepage/today/todayRecentObjects.test.ts b/frontend/src/scenes/project-homepage/today/todayRecentObjects.test.ts
index 2dc178dbb432..1bee2d46897b 100644
--- a/frontend/src/scenes/project-homepage/today/todayRecentObjects.test.ts
+++ b/frontend/src/scenes/project-homepage/today/todayRecentObjects.test.ts
@@ -1,15 +1,66 @@
-import { recentObjectsForHome } from './todayRecentObjects'
+import { jumpBackObjectsForHome, recentObjectsForHome } from './todayRecentObjects'
describe('recentObjectsForHome', () => {
it('keeps agent sessions and entries with no page out of the recent objects', () => {
const recents = [
{ id: 'task', path: 'Tasks/Fix the flaky test', type: 'task', href: '/tasks/1' },
{ id: 'dashboard', path: 'Dashboards/Growth', type: 'dashboard', href: '/dashboard/1' },
+ { id: 'dashboard-copy', path: 'Pinned/Growth', type: 'dashboard', ref: '1' },
{ id: 'unlinked', path: 'Insights/Draft', type: 'insight' },
{ id: 'by-ref', path: 'Flags/checkout', type: 'feature_flag', ref: '7' },
{ id: 'insight', path: 'Insights/Funnel', type: 'insight/funnels', href: '/insights/a' },
+ { id: 'insight-copy', path: 'Pinned/Funnel', type: 'insight', ref: 'a' },
+ { id: 'query-one', path: 'Queries/One', href: '/sql?open_saved_query=one' },
+ { id: 'query-two', path: 'Queries/Two', href: '/sql?open_saved_query=two' },
]
- expect(recentObjectsForHome(recents, 3).map((entry) => entry.id)).toEqual(['dashboard', 'by-ref', 'insight'])
+ expect(recentObjectsForHome(recents, 5).map((entry) => entry.id)).toEqual([
+ 'dashboard',
+ 'by-ref',
+ 'insight',
+ 'query-one',
+ 'query-two',
+ ])
+ })
+
+ it.each([
+ [
+ ['a', 'b', 'c', 'd', 'e'],
+ ['d', 'e'],
+ ['a', 'b', 'd', 'e', 'c'],
+ ],
+ [
+ ['a', 'b', 'c', 'd', 'e'],
+ ['a', 'b', 'd', 'e'],
+ ['a', 'b', 'd', 'e', 'c'],
+ ],
+ [['a', 'b', 'c', 'd', 'e'], ['d'], ['a', 'b', 'd', 'c', 'e']],
+ [['a', 'b', 'c', 'd', 'e'], [], ['a', 'b', 'c', 'd', 'e']],
+ [
+ ['a', 'b', 'c', 'd', 'e'],
+ ['d', 'd', 'e'],
+ ['a', 'b', 'd', 'e', 'c'],
+ ],
+ [
+ ['a', 'a', 'b', 'c', 'd', 'e'],
+ ['c', 'd'],
+ ['a', 'b', 'c', 'd', 'e'],
+ ],
+ [
+ ['a', 'b', 'c', 'c', 'd', 'e'],
+ ['d', 'e'],
+ ['a', 'b', 'd', 'e', 'c'],
+ ],
+ [['a', 'a', 'b', 'c', 'd', 'e'], [], ['a', 'b', 'c', 'd', 'e']],
+ ])('mixes recent %j and frequent %j without duplicates', (recentIds, frequentIds, expected) => {
+ const entry = (id: string): { id: string; path: string; type: string; ref: string } => ({
+ id,
+ path: `Dashboards/${id}`,
+ type: 'dashboard',
+ ref: id,
+ })
+ expect(jumpBackObjectsForHome(recentIds.map(entry), frequentIds.map(entry), 8).map((item) => item.id)).toEqual(
+ expected
+ )
})
})
diff --git a/frontend/src/scenes/project-homepage/today/todayRecentObjects.ts b/frontend/src/scenes/project-homepage/today/todayRecentObjects.ts
index 0ebe4312d5f8..894d00363457 100644
--- a/frontend/src/scenes/project-homepage/today/todayRecentObjects.ts
+++ b/frontend/src/scenes/project-homepage/today/todayRecentObjects.ts
@@ -1,10 +1,63 @@
+import { removeProjectIdIfPresent } from 'lib/utils/kea-router'
import { baseObjectType, libraryObjectHref } from 'scenes/library/libraryUtils'
import { FileSystemEntry } from '~/queries/schema/schema-general'
+function objectKey(entry: FileSystemEntry): string {
+ const href = libraryObjectHref({ type: baseObjectType(entry.type), ref: entry.ref }) ?? libraryObjectHref(entry)
+ if (!href) {
+ return `${baseObjectType(entry.type)}:${entry.ref ?? entry.id}`
+ }
+ const destination = removeProjectIdIfPresent(href).split('#')[0]
+ const queryStart = destination.indexOf('?')
+ const pathname = queryStart === -1 ? destination : destination.slice(0, queryStart)
+ const search = queryStart === -1 ? '' : destination.slice(queryStart)
+ return `${pathname.replace(/\/+$/, '')}${search}`
+}
+
/** Recently viewed objects for the home, without tasks, which are agent sessions rather than things people look at. */
export function recentObjectsForHome(recents: FileSystemEntry[], limit: number): FileSystemEntry[] {
+ const seen = new Set()
return recents
- .filter((entry) => libraryObjectHref(entry) !== null && baseObjectType(entry.type) !== 'task')
+ .filter((entry) => {
+ if (libraryObjectHref(entry) === null || baseObjectType(entry.type) === 'task') {
+ return false
+ }
+ const key = objectKey(entry)
+ if (seen.has(key)) {
+ return false
+ }
+ seen.add(key)
+ return true
+ })
.slice(0, limit)
}
+
+export type JumpBackObject = FileSystemEntry & { jumpBackReason: 'recent' | 'frequent' }
+
+export function jumpBackObjectsForHome(
+ recents: FileSystemEntry[],
+ frequent: FileSystemEntry[],
+ limit: number
+): JumpBackObject[] {
+ const recentObjects = recentObjectsForHome(recents, limit)
+ const first = recentObjects.slice(0, 2)
+ const seen = new Set(first.map(objectKey))
+ const result: JumpBackObject[] = first.map((entry) => ({ ...entry, jumpBackReason: 'recent' }))
+ for (const entry of recentObjectsForHome(frequent, frequent.length)) {
+ if (!seen.has(objectKey(entry))) {
+ seen.add(objectKey(entry))
+ result.push({ ...entry, jumpBackReason: 'frequent' })
+ if (result.length === first.length + 2) {
+ break
+ }
+ }
+ }
+ for (const entry of recentObjects) {
+ if (!seen.has(objectKey(entry))) {
+ seen.add(objectKey(entry))
+ result.push({ ...entry, jumpBackReason: 'recent' })
+ }
+ }
+ return result.slice(0, limit)
+}
diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx
index 8653cd4f7e4f..acb7b275edc7 100644
--- a/frontend/src/scenes/settings/SettingsMap.tsx
+++ b/frontend/src/scenes/settings/SettingsMap.tsx
@@ -1679,7 +1679,6 @@ export const SETTINGS_MAP: SettingSection[] = [
description:
'Let business knowledge read these repositories when answering a question. It searches file names and the README, then reads a file. It does not index the code.',
component: ,
- flag: 'BUSINESS_KNOWLEDGE_GITHUB_REPOS',
keywords: ['business', 'knowledge', 'github', 'repository', 'code'],
},
],
diff --git a/frontend/src/scenes/team-activity/teamActivityDescriber.tsx b/frontend/src/scenes/team-activity/teamActivityDescriber.tsx
index f491a1405671..b03e9b40c018 100644
--- a/frontend/src/scenes/team-activity/teamActivityDescriber.tsx
+++ b/frontend/src/scenes/team-activity/teamActivityDescriber.tsx
@@ -16,7 +16,7 @@ import PropertyFiltersDisplay from 'lib/components/PropertyFilters/components/Pr
import { Link } from 'lib/lemon-ui/Link'
import { CURRENCY_SYMBOL_TO_EMOJI_MAP, CURRENCY_SYMBOL_TO_NAME_MAP } from 'lib/utils/currency'
import { isObject } from 'lib/utils/guards'
-import { pluralize } from 'lib/utils/strings'
+import { capitalizeFirstLetter, pluralize } from 'lib/utils/strings'
import { ExperimentsConfig } from 'scenes/settings/environment/experimentsConfigLogic'
import { urls } from 'scenes/urls'
@@ -868,6 +868,33 @@ function describeWorkflowEmailSuspension(logItem: ActivityLogItem): HumanizedCha
}
}
+function describeWorkflowEmailTierChange(logItem: ActivityLogItem): HumanizedChange {
+ const changes = logItem.detail?.changes || []
+ const tierChange = changes.find((change) => change.field === 'email_sending_tier')
+ const pinChange = changes.find((change) => change.field === 'email_sending_tier_pinned')
+ const contextReason = logItem.detail?.context?.reason as string | undefined
+ // A staff-set tier has no decision behind it, so the row says "set" and shows no reason.
+ const setByStaff = contextReason === 'staff_set'
+ const reason = setByStaff ? undefined : contextReason
+ const target = shortIdActivityLink(logItem, urls.notebook)
+ const pinVerb = pinChange ? (pinChange.after ? 'pinned' : 'unpinned') : null
+ const verb = tierChange ? (setByStaff ? 'set' : 'moved') : (pinVerb ?? 'changed')
+ const tierMove = tierChange ? ` from ${String(tierChange.before)} to ${String(tierChange.after)}` : ''
+ const pinSuffix = tierChange && pinVerb ? ` and ${pinVerb === 'pinned' ? 'pinned it there' : 'unpinned it'}` : ''
+ const action = `${verb} the workflow email sending tier`
+ return {
+ summary: activityLogSummary(logItem, `${capitalizeFirstLetter(action)}${tierMove}${pinSuffix}`, target, reason),
+ description: (
+ <>
+ {action} on {target}
+ {tierMove}
+ {pinSuffix}
+ {reason ? <> (reason: {reason})> : null}
+ >
+ ),
+ }
+}
+
export function teamActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange {
if (logItem.scope !== ActivityScope.TEAM) {
console.error('team describer received a non-Team activity')
@@ -878,6 +905,10 @@ export function teamActivityDescriber(logItem: ActivityLogItem, asNotification?:
return describeWorkflowEmailSuspension(logItem)
}
+ if (logItem.activity === 'email_sending_tier_changed') {
+ return describeWorkflowEmailTierChange(logItem)
+ }
+
if (logItem.activity == 'changed' || logItem.activity == 'updated') {
const changes = describeMappedChanges(
logItem,
diff --git a/nodejs/src/cdp/cdp-e2e.serial.test.ts b/nodejs/src/cdp/cdp-e2e.serial.test.ts
index ad0d206a0511..b29004a886ef 100644
--- a/nodejs/src/cdp/cdp-e2e.serial.test.ts
+++ b/nodejs/src/cdp/cdp-e2e.serial.test.ts
@@ -197,6 +197,7 @@ describe('CDP Consumer loop', () => {
"headers": {
"Authorization": "Bearer super-secret-token",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
diff --git a/nodejs/src/cdp/consumers/cdp-cyclotron-worker-plugins.test.ts b/nodejs/src/cdp/consumers/cdp-cyclotron-worker-plugins.test.ts
index 0cd0a382dfad..3a9e9b48d86d 100644
--- a/nodejs/src/cdp/consumers/cdp-cyclotron-worker-plugins.test.ts
+++ b/nodejs/src/cdp/consumers/cdp-cyclotron-worker-plugins.test.ts
@@ -155,6 +155,7 @@ describe('CdpCyclotronWorkerPlugins', () => {
"Authorization": "Bearer 1234567890",
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -169,6 +170,7 @@ describe('CdpCyclotronWorkerPlugins', () => {
"Authorization": "Bearer 1234567890",
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
diff --git a/nodejs/src/cdp/services/hog-executor-async.service.ts b/nodejs/src/cdp/services/hog-executor-async.service.ts
index 2c8878bc4bc2..c914db4cba34 100644
--- a/nodejs/src/cdp/services/hog-executor-async.service.ts
+++ b/nodejs/src/cdp/services/hog-executor-async.service.ts
@@ -18,7 +18,12 @@ import type {
} from '../types'
import { createAddLogFunction, destinationE2eLagMsSummary } from '../utils'
import { resolveAwsSigV4Credentials, signAwsRequest } from '../utils/aws-sigv4'
-import { cdpTrackedFetch, fetchErrorDetail, isFetchResponseRetriable } from '../utils/cdp-fetch'
+import {
+ MAX_FETCH_RESPONSE_BYTES,
+ cdpTrackedFetch,
+ fetchErrorDetail,
+ isFetchResponseRetriable,
+} from '../utils/cdp-fetch'
import { createInvocationResult } from '../utils/invocation-utils'
import { isNonFailureStatus } from '../utils/non-failure-status-codes'
import { ScopedServiceJwt } from '../utils/scoped-service-jwt'
@@ -579,7 +584,12 @@ export class HogExecutorAsyncService {
}
}
} catch (e) {
- addLog('error', `Failed to parse response body: ${e.message}`)
+ addLog(
+ 'error',
+ e.name === 'ResponseTooLargeError'
+ ? `The response body is larger than ${MAX_FETCH_RESPONSE_BYTES / 1024 / 1024} MB, so it was not read.`
+ : `Failed to parse response body: ${e.message}`
+ )
body = undefined
}
diff --git a/nodejs/src/cdp/services/legacy-plugin-executor.service.test.ts b/nodejs/src/cdp/services/legacy-plugin-executor.service.test.ts
index 55f24f637f07..ac6fe565a54a 100644
--- a/nodejs/src/cdp/services/legacy-plugin-executor.service.test.ts
+++ b/nodejs/src/cdp/services/legacy-plugin-executor.service.test.ts
@@ -255,6 +255,7 @@ describe('LegacyPluginExecutorService', () => {
"Authorization": "Basic MTIzNDU2Nzg5MDpjaW8tdG9rZW4=",
"User-Agent": "PostHog Customer.io App",
},
+ "maxResponseBytes": 5242880,
"method": "GET",
},
],
@@ -267,6 +268,7 @@ describe('LegacyPluginExecutorService', () => {
"Content-Type": "application/json",
"User-Agent": "PostHog Customer.io App",
},
+ "maxResponseBytes": 5242880,
"method": "PUT",
},
],
@@ -279,6 +281,7 @@ describe('LegacyPluginExecutorService', () => {
"Content-Type": "application/json",
"User-Agent": "PostHog Customer.io App",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
],
diff --git a/nodejs/src/cdp/services/native-destination-executor.service.test.ts b/nodejs/src/cdp/services/native-destination-executor.service.test.ts
index 64a21c4a7480..114aa5b05c0b 100644
--- a/nodejs/src/cdp/services/native-destination-executor.service.test.ts
+++ b/nodejs/src/cdp/services/native-destination-executor.service.test.ts
@@ -105,6 +105,7 @@ describe('NativeDestinationExecutorService', () => {
"Content-Type": "application/json",
"User-Agent": "PostHog.com/1.0",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -170,6 +171,7 @@ describe('NativeDestinationExecutorService', () => {
"Content-Type": "application/json",
"User-Agent": "PostHog.com/1.0",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -192,6 +194,31 @@ describe('NativeDestinationExecutorService', () => {
})
})
+ it('retries a retryable status whose body is over the size cap', async () => {
+ const invocation = createExampleNativeInvocation(
+ createHogFunction({ name: 'Plugin test', template_id: 'native-webhook' }),
+ inputs
+ )
+ const tooLarge = Object.assign(new Error('Response body is larger than the 5242880 byte limit'), {
+ name: 'ResponseTooLargeError',
+ })
+ mockFetch.mockResolvedValue({
+ status: 503,
+ json: () => Promise.reject(tooLarge),
+ text: () => Promise.reject(tooLarge),
+ headers: {},
+ dump: () => Promise.resolve(),
+ })
+
+ const result = await service.execute(invocation)
+
+ expect(result.finished).toBe(false)
+ expect(result.invocation.queueMetadata).toEqual({ tries: 1 })
+ expect(result.logs.map((x) => x.message)).toContainEqual(
+ expect.stringContaining('HTTP request failed with status 503 (Response body is larger than')
+ )
+ })
+
it('should retry retryable fetch errors', async () => {
const fn = createHogFunction({
name: 'Plugin test',
@@ -231,6 +258,7 @@ describe('NativeDestinationExecutorService', () => {
"Content-Type": "application/json",
"User-Agent": "PostHog.com/1.0",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
diff --git a/nodejs/src/cdp/services/native-destination-executor.service.ts b/nodejs/src/cdp/services/native-destination-executor.service.ts
index 3fe920fa1bc0..6b35027f431f 100644
--- a/nodejs/src/cdp/services/native-destination-executor.service.ts
+++ b/nodejs/src/cdp/services/native-destination-executor.service.ts
@@ -15,7 +15,13 @@ import {
redactError,
redactSensitiveValues,
} from '../utils'
-import { CdpFetchConfig, cdpTrackedFetch, getNextRetryTime, isFetchResponseRetriable } from '../utils/cdp-fetch'
+import {
+ CdpFetchConfig,
+ cdpTrackedFetch,
+ getNextRetryTime,
+ isFetchResponseRetriable,
+ readFetchResponseText,
+} from '../utils/cdp-fetch'
import { createInvocationResult } from '../utils/invocation-utils'
const nativeDestinationExecutionDuration = new Histogram({
@@ -175,7 +181,7 @@ export class NativeDestinationExecutorService {
hogFunctionId: invocation.hogFunction.id,
})
- const fetchResponseText = (await fetchResponse?.text()) ?? 'unknown'
+ const fetchResponseText = await readFetchResponseText(fetchResponse)
if (fetchError || !fetchResponse || fetchResponse.status >= 400) {
if (
diff --git a/nodejs/src/cdp/services/segment-destination-executor.service.test.ts b/nodejs/src/cdp/services/segment-destination-executor.service.test.ts
index 439e068e5131..8659b951e8ef 100644
--- a/nodejs/src/cdp/services/segment-destination-executor.service.test.ts
+++ b/nodejs/src/cdp/services/segment-destination-executor.service.test.ts
@@ -107,6 +107,7 @@ describe('SegmentDestinationExecutorService', () => {
"headers": {
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -198,6 +199,7 @@ describe('SegmentDestinationExecutorService', () => {
"headers": {
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -262,6 +264,7 @@ describe('SegmentDestinationExecutorService', () => {
"headers": {
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -390,6 +393,7 @@ describe('SegmentDestinationExecutorService', () => {
"Content-Type": "application/json",
"x-gb-agent": "GB/Segment",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
@@ -466,6 +470,7 @@ describe('SegmentDestinationExecutorService', () => {
{
"body": undefined,
"headers": {},
+ "maxResponseBytes": 5242880,
"method": "GET",
},
]
@@ -478,6 +483,7 @@ describe('SegmentDestinationExecutorService', () => {
"headers": {
"Content-Type": "application/json",
},
+ "maxResponseBytes": 5242880,
"method": "POST",
},
]
diff --git a/nodejs/src/cdp/services/segment-destination-executor.service.ts b/nodejs/src/cdp/services/segment-destination-executor.service.ts
index d20d9f648a76..f7d95f674db9 100644
--- a/nodejs/src/cdp/services/segment-destination-executor.service.ts
+++ b/nodejs/src/cdp/services/segment-destination-executor.service.ts
@@ -16,7 +16,13 @@ import {
redactError,
redactSensitiveValues,
} from '../utils'
-import { CdpFetchConfig, cdpTrackedFetch, getNextRetryTime, isFetchResponseRetriable } from '../utils/cdp-fetch'
+import {
+ CdpFetchConfig,
+ cdpTrackedFetch,
+ getNextRetryTime,
+ isFetchResponseRetriable,
+ readFetchResponseText,
+} from '../utils/cdp-fetch'
import { createInvocationResult } from '../utils/invocation-utils'
const pluginExecutionDuration = new Histogram({
@@ -310,7 +316,7 @@ export class SegmentDestinationExecutorService {
hogFunctionId: invocation.hogFunction.id,
})
- const fetchResponseText = (await fetchResponse?.text()) ?? 'unknown'
+ const fetchResponseText = await readFetchResponseText(fetchResponse)
if (fetchError || !fetchResponse || fetchResponse.status >= 400) {
if (
diff --git a/nodejs/src/cdp/utils/cdp-fetch.ts b/nodejs/src/cdp/utils/cdp-fetch.ts
index 2464864cc8be..5f3fd0df2937 100644
--- a/nodejs/src/cdp/utils/cdp-fetch.ts
+++ b/nodejs/src/cdp/utils/cdp-fetch.ts
@@ -9,6 +9,23 @@ import { tryCatch } from '~/common/utils/try-catch'
import { PluginsServerConfig } from '../../types'
export const MAX_FETCH_TIMEOUT_MS = 10000
+// The response lands in the invocation state, which can be queued through Kafka with a 6 MB message limit.
+export const MAX_FETCH_RESPONSE_BYTES = 5 * 1024 * 1024
+
+// An error body is only logged, so an oversized one must not hide the status that decides the retry.
+export async function readFetchResponseText(response: FetchResponse | null | undefined): Promise {
+ if (!response) {
+ return 'unknown'
+ }
+ try {
+ return await response.text()
+ } catch (e) {
+ if (e.name === 'ResponseTooLargeError' && response.status >= 400) {
+ return e.message
+ }
+ throw e
+ }
+}
/** Narrowed config type for CDP fetch retry settings, used by native/segment destination executors */
export type CdpFetchConfig = Pick<
@@ -134,7 +151,7 @@ export async function cdpTrackedFetch({
// which run inside undici's connect flow where no caller context is otherwise available.
const doFetch = () =>
fetchAttribution.run({ teamId, hogFunctionId, templateId }, () =>
- tryCatch(async () => await fetch(url, fetchParams))
+ tryCatch(async () => await fetch(url, { maxResponseBytes: MAX_FETCH_RESPONSE_BYTES, ...fetchParams }))
)
let [fetchError, fetchResponse] = await doFetch()
diff --git a/nodejs/src/common/utils/request.test.ts b/nodejs/src/common/utils/request.test.ts
index f184d2ff8ab7..7c4037ac8416 100644
--- a/nodejs/src/common/utils/request.test.ts
+++ b/nodejs/src/common/utils/request.test.ts
@@ -10,6 +10,7 @@ import { parseJSON } from './json-parse'
import { logger } from './logger'
import {
FetchOptions,
+ ResponseTooLargeError,
SecureRequestError,
fetch,
internalFetch,
@@ -38,6 +39,9 @@ beforeAll(async () => {
} else if (url.pathname === '/status/404') {
res.writeHead(404)
res.end()
+ } else if (url.pathname === '/bom') {
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end('\ufeff{"ok":true}')
} else if (url.pathname === '/stream/50') {
res.writeHead(200, { 'content-type': 'application/json' })
for (let i = 0; i < 50; i++) {
@@ -728,6 +732,29 @@ describe('_fetch response body handling', () => {
expect(response.headers['content-type']).toBeDefined()
})
+ const streamedBodyBytes = range(50).reduce((total, i) => total + JSON.stringify({ id: i }).length + 1, 0)
+
+ it.each([
+ ['under the limit', streamedBodyBytes + 1, false],
+ ['exactly at the limit', streamedBodyBytes, false],
+ ['over the limit', streamedBodyBytes - 1, true],
+ ])('caps a streamed body %s', async (_name, maxResponseBytes, rejects) => {
+ const response = await internalFetch(`${baseUrl}/stream/50`, { maxResponseBytes })
+ if (rejects) {
+ await expect(response.text()).rejects.toBeInstanceOf(ResponseTooLargeError)
+ } else {
+ expect(await response.text()).toHaveLength(streamedBodyBytes)
+ }
+ })
+
+ it.each([
+ ['uncapped', undefined],
+ ['capped', 1024],
+ ])('strips a leading BOM from a %s body', async (_name, maxResponseBytes) => {
+ const response = await internalFetch(`${baseUrl}/bom`, { maxResponseBytes })
+ expect(await response.json()).toEqual({ ok: true })
+ })
+
it('should fully read streamed/chunked response bodies', async () => {
const response = await internalFetch(`${baseUrl}/stream/50`)
const text = await response.text()
diff --git a/nodejs/src/common/utils/request.ts b/nodejs/src/common/utils/request.ts
index b8ca00ba7b71..d418a74378f2 100644
--- a/nodejs/src/common/utils/request.ts
+++ b/nodejs/src/common/utils/request.ts
@@ -135,6 +135,7 @@ export type FetchOptions = {
// Requests with different groups never share an HTTP/2 session. APNs can reject a provider token from one developer
// team on a session that already carried another team's token. Requires `allowH2`.
http2ConnectionGroup?: string
+ maxResponseBytes?: number
}
export type FetchResponse = {
@@ -161,6 +162,13 @@ export class InvalidRequestError extends Error {
}
}
+export class ResponseTooLargeError extends Error {
+ constructor(maxBytes: number) {
+ super(`Response body is larger than the ${maxBytes} byte limit`)
+ this.name = 'ResponseTooLargeError'
+ }
+}
+
export class ResolutionError extends Error {
constructor(message: string) {
super(message)
@@ -750,7 +758,18 @@ export async function _fetch(
const readBody = (): Promise => {
if (!bodyPromise) {
- bodyPromise = readAndDestroyBody(result.body).finally(() => lifecycle?.onBodyDone())
+ const maxBytes = options.maxResponseBytes
+ const read =
+ maxBytes === undefined
+ ? readAndDestroyBody(result.body)
+ : readCappedBody(result.body, maxBytes, false).then(({ bytes, overLimit }) => {
+ if (overLimit) {
+ throw new ResponseTooLargeError(maxBytes)
+ }
+ const text = bytes.toString('utf8')
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
+ })
+ bodyPromise = read.finally(() => lifecycle?.onBodyDone())
}
return bodyPromise
}
diff --git a/packages/agent/packages/agent-contracts/src/domain-types.ts b/packages/agent/packages/agent-contracts/src/domain-types.ts
index 0271a504a55e..2acc07d14306 100644
--- a/packages/agent/packages/agent-contracts/src/domain-types.ts
+++ b/packages/agent/packages/agent-contracts/src/domain-types.ts
@@ -786,6 +786,10 @@ export interface SignalReport {
is_suggested_reviewer?: boolean;
/** Distinct source products contributing signals to this report. */
source_products?: string[];
+ /** Whether the report proposes an improvement to a scout. */
+ is_self_improvement_report?: boolean;
+ /** Selected code repository, when the report has one. */
+ repo_slug?: string | null;
/** PR URL from the latest implementation task run, if available. */
implementation_pr_url?: string | null;
work_state?: "unclaimed" | "working" | "in_review" | "done";
diff --git a/packages/agent/packages/agent-contracts/src/model-catalog.generated.ts b/packages/agent/packages/agent-contracts/src/model-catalog.generated.ts
index aefcb8047ec9..f4733754f04e 100644
--- a/packages/agent/packages/agent-contracts/src/model-catalog.generated.ts
+++ b/packages/agent/packages/agent-contracts/src/model-catalog.generated.ts
@@ -478,6 +478,25 @@ export const MODELS: readonly CatalogModel[] = [
costSummary:
"Per 1M tokens: $2 input/$10 output to 272K; $4 input/$15 output above",
},
+ {
+ id: "gpt-6.1-terra",
+ runtimeAdapter: "codex",
+ reasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
+ label: "GPT-6.1 Terra",
+ cost: {
+ inputPerMtok: 1,
+ outputPerMtok: 5,
+ longContext: {
+ aboveInputTokens: 272000,
+ inputPerMtok: 2,
+ outputPerMtok: 7.5,
+ },
+ },
+ costMultiplier: "0.5× base",
+ costSummary:
+ "Per 1M tokens: $1 input/$5 output to 272K; $2 input/$7.50 output above",
+ retired: true,
+ },
{
id: "gpt-6-luna",
runtimeAdapter: "codex",
diff --git a/packages/quill/packages/charts/src/charts/BarList/StackedBarList.tsx b/packages/quill/packages/charts/src/charts/BarList/StackedBarList.tsx
index 769f58f5d362..ef0a67e5bb51 100644
--- a/packages/quill/packages/charts/src/charts/BarList/StackedBarList.tsx
+++ b/packages/quill/packages/charts/src/charts/BarList/StackedBarList.tsx
@@ -41,6 +41,7 @@ export interface StackedBarListProps {
onPointClick?: (data: PointClickData ) => void
dataAttr?: string
onError?: (error: Error, info: React.ErrorInfo) => void
+ children?: React.ReactNode
}
const DEFAULT_ROW_HEIGHT = { start: 28, top: 40 }
@@ -71,6 +72,7 @@ function StackedBarListInner ({
onPointClick,
dataAttr,
onError,
+ children,
}: StackedBarListProps ): React.ReactElement {
const labelPosition = config?.labelPosition ?? 'start'
const {
@@ -135,7 +137,9 @@ function StackedBarListInner ({
onPointClick={onPointClick}
dataAttr={dataAttr}
onError={onError}
- />
+ >
+ {children}
+
) : null
if (labelPosition === 'top') {
diff --git a/packages/quill/packages/charts/src/docs/overlays.md b/packages/quill/packages/charts/src/docs/overlays.md
index 54f7ce294cd1..0e80a7adcbe5 100644
--- a/packages/quill/packages/charts/src/docs/overlays.md
+++ b/packages/quill/packages/charts/src/docs/overlays.md
@@ -10,6 +10,8 @@ They compose as children of a chart:
```
+`StackedBarList` also accepts overlay children. It places them in the chart area, which excludes the label and value cells.
+
Trend lines, moving averages, and confidence bands are not overlays.
They are derived `Series` entries (`overlay: true` for the first two), so the legend, the tooltip, and the axis already know about them.
diff --git a/posthog/admin/admins/team_admin.py b/posthog/admin/admins/team_admin.py
index a1835e0f0bc7..402bc27a4a8a 100644
--- a/posthog/admin/admins/team_admin.py
+++ b/posthog/admin/admins/team_admin.py
@@ -7,7 +7,7 @@
import dataclasses
from datetime import UTC, datetime, timedelta
from decimal import Decimal, InvalidOperation
-from typing import Any
+from typing import Any, cast
from urllib import parse
from django.conf import settings
@@ -36,7 +36,7 @@
from posthog.admin.inlines.team_marketing_analytics_config_inline import TeamMarketingAnalyticsConfigInline
from posthog.helpers.impersonation import is_impersonated
from posthog.llm.gateway_internal_client import AIGatewayInternalError, AIGatewayNotConfigured, add_credit, get_wallet
-from posthog.models import Team
+from posthog.models import Team, User
from posthog.models.activity_logging.activity_log import ActivityContextBase, ActivityLog, Detail, log_activity
from posthog.models.events_retention_config import (
OrganizationEventsRetentionConfig,
@@ -839,7 +839,9 @@ def suspend_email_sending_view(self, request, object_id):
# The facade row-locks the config while checking + flipping, so concurrent submits can't
# both dispatch the customer email + notification. Side effects stay outside that lock.
- change = suspend_email_sending(team.pk, reason)
+ change = suspend_email_sending(
+ team.pk, reason, user_id=cast(User, request.user).pk, was_impersonated=is_impersonated(request)
+ )
already_suspended_at = change.previously_suspended_at
suspended_at = change.changed_at
@@ -1036,7 +1038,13 @@ def set_email_sending_tier_view(self, request: HttpRequest, object_id: str) -> H
return redirect(team_url)
pinned = request.POST.get("pinned") == "on"
- previous_tier = set_email_sending_tier(team.pk, tier=tier, pinned=pinned)
+ previous_tier = set_email_sending_tier(
+ team.pk,
+ tier=tier,
+ pinned=pinned,
+ user_id=cast(User, request.user).pk,
+ was_impersonated=is_impersonated(request),
+ )
logger.info(
"admin_set_email_sending_tier",
@@ -1070,7 +1078,9 @@ def recompute_email_sending_tier_view(self, request: HttpRequest, object_id: str
# suspend and set-tier actions.
ensure_workflows_config(team.pk)
try:
- decision = recompute_email_sending_tier(team.id)
+ decision = recompute_email_sending_tier(
+ team.id, user_id=cast(User, request.user).pk, was_impersonated=is_impersonated(request)
+ )
except Exception:
logger.exception("admin_recompute_email_sending_tier_failed", team_id=team.id)
self.message_user(request, "Could not recompute the tier. Check the logs.", level=messages.ERROR)
diff --git a/posthog/admin/test_team_admin.py b/posthog/admin/test_team_admin.py
index b45e0d71bbff..2723fed59760 100644
--- a/posthog/admin/test_team_admin.py
+++ b/posthog/admin/test_team_admin.py
@@ -25,7 +25,6 @@
)
from posthog.models import Organization
from posthog.models.activity_logging.activity_log import ActivityLog
-from posthog.models.team.extensions import get_or_create_team_extension
from posthog.models.team.team import Team
from posthog.personhog_client.fake_client import FakePersonHogClient
from posthog.personhog_client.proto import GetGroupTypeMappingsByProjectIdRequest
@@ -680,8 +679,8 @@ def test_suspend_sets_fields_logs_activity_and_notifies(self) -> None:
assert self.mock_notification.call_count == 1
def test_suspend_drops_the_tier_to_zero_even_when_pinned(self) -> None:
- config = get_or_create_team_extension(
- self.team, TeamWorkflowsConfig, defaults={"email_sending_tier": 3, "email_sending_tier_pinned": True}
+ config, _ = TeamWorkflowsConfig.objects.update_or_create(
+ team=self.team, defaults={"email_sending_tier": 3, "email_sending_tier_pinned": True}
)
response = self.admin.suspend_email_sending_view(self._post({"reason": "spam complaints"}), str(self.team.pk))
@@ -692,6 +691,39 @@ def test_suspend_drops_the_tier_to_zero_even_when_pinned(self) -> None:
assert config.email_sending_tier == 0
assert config.email_sending_tier_updated_at is not None
+ entry = ActivityLog.objects.get(scope="Team", team_id=self.team.id, activity="email_sending_tier_changed")
+ assert entry.user == self.user
+ assert (entry.detail or {})["context"] == {"reason": "staff_suspension"}
+
+ @parameterized.expand(
+ [
+ ("tier_change", {"tier": "2"}, [("email_sending_tier", 1, 2)]),
+ ("pin_only", {"tier": "1", "pinned": "on"}, [("email_sending_tier_pinned", False, True)]),
+ ("no_change", {"tier": "1"}, None),
+ ]
+ )
+ def test_set_tier_audits_what_changed(
+ self, _name: str, form: dict[str, str], expected_changes: list[tuple[str, object, object]] | None
+ ) -> None:
+ TeamWorkflowsConfig.objects.update_or_create(
+ team=self.team, defaults={"email_sending_tier": 1, "email_sending_tier_pinned": False}
+ )
+
+ response = self.admin.set_email_sending_tier_view(self._post(form), str(self.team.pk))
+ assert response.status_code == 302
+
+ entries = list(
+ ActivityLog.objects.filter(scope="Team", team_id=self.team.id, activity="email_sending_tier_changed")
+ )
+ if expected_changes is None:
+ assert entries == []
+ return
+ assert len(entries) == 1
+ assert entries[0].user == self.user
+ detail = entries[0].detail or {}
+ assert detail["context"] == {"reason": "staff_set"}
+ assert [(c["field"], c["before"], c["after"]) for c in detail["changes"]] == expected_changes
+
def test_tier_actions_render_without_a_nested_form(self) -> None:
# The field renders inside the admin's team change form. A nested
- {githubEnabled && (
- }
- onClick={() => openLibraryDialog('repos')}
- data-attr="business-knowledge-library-repos"
- >
- Repositories
-
- )}
+ }
+ onClick={() => openLibraryDialog('repos')}
+ data-attr="business-knowledge-library-repos"
+ >
+ Repositories
+
{counts.join(' · ')}
{selectedLibrary.is_default && . Sources learned from support tickets join this library. }
- {githubEnabled && selectedLibrary.repos.length > 0 && (
+ {selectedLibrary.repos.length > 0 && (
{selectedLibrary.repos.map((repo) => (
}>
diff --git a/products/business_knowledge/mcp/tools.yaml b/products/business_knowledge/mcp/tools.yaml
index 7f96d1651361..337321f82786 100644
--- a/products/business_knowledge/mcp/tools.yaml
+++ b/products/business_knowledge/mcp/tools.yaml
@@ -126,7 +126,7 @@ tools:
business-knowledge-repositories-search. Returns the file text and a permalink. Does not search file
contents. Pass library_id to read a repository in that library, using the same id as document search and
repository search. Omit library_id to read any selected repository.
- feature_flag: business-knowledge-github-repos
+ feature_flag: product-business-knowledge
business-knowledge-repositories-search:
operation: business_knowledge_repositories_search
enabled: true
@@ -144,7 +144,7 @@ tools:
holds a handbook or docs, search that repository for those words and read the file. Does not search inside
file contents and does not call GitHub code search. Pass library_id to search repositories in that library,
using the same id as document search and file read. Omit library_id to search every selected repository.
- feature_flag: business-knowledge-github-repos
+ feature_flag: product-business-knowledge
business-knowledge-sources-list:
operation: business_knowledge_sources_list
enabled: true
diff --git a/products/cdp/backend/api/hog_function.py b/products/cdp/backend/api/hog_function.py
index 8df10aeb1498..1e788be88e0c 100644
--- a/products/cdp/backend/api/hog_function.py
+++ b/products/cdp/backend/api/hog_function.py
@@ -646,6 +646,14 @@ def to_internal_value(self, data):
raise serializers.ValidationError({"template_id": f"No template found for id '{data['template_id']}'"})
+ # A test run of an unsaved function takes template_id from the caller, and the template decides
+ # which reserved functions the code may call, so it gets the same check as a create.
+ is_unsaved_test = (
+ instance is None and self.context.get("view") and self.context["view"].action == "invocations"
+ )
+ if is_unsaved_test:
+ self._validate_template_is_creatable(template)
+
if is_create:
# Set defaults for new functions
data["inputs_schema"] = data.get("inputs_schema") or []
diff --git a/products/cdp/backend/api/test/test_hog_function.py b/products/cdp/backend/api/test/test_hog_function.py
index e68d3d566a74..5d3a830f61ec 100644
--- a/products/cdp/backend/api/test/test_hog_function.py
+++ b/products/cdp/backend/api/test/test_hog_function.py
@@ -2802,6 +2802,30 @@ def test_test_invocation_rejects_a_posthog_connection_input(self):
assert "PostHog connection" in json.dumps(response.json())
mock_create_hog_invocation_test.assert_not_called()
+ def test_test_invocation_of_unsaved_function_cannot_borrow_a_hidden_templates_reserved_calls(self):
+ self._create_email_template()
+
+ with patch(
+ "products.cdp.backend.api.hog_function.create_hog_invocation_test"
+ ) as mock_create_hog_invocation_test:
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/hog_functions/new/invocations/",
+ data={
+ "configuration": {
+ "type": "destination",
+ "name": "Sneaky",
+ "template_id": "template-email-test",
+ "hog": "sendEmail({'to': {'email': 'someone@example.com'}, 'subject': 'hi'})",
+ "inputs": {},
+ },
+ "mock_async_functions": False,
+ },
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json()
+ assert "is internal and cannot be used" in json.dumps(response.json())
+ mock_create_hog_invocation_test.assert_not_called()
+
@parameterized.expand(
[
("errors_list", {"errors": ["Missing event"]}, ["Missing event"]),
diff --git a/products/conversations/backend/ai/human_outcome.py b/products/conversations/backend/ai/human_outcome.py
index 062b5b36b529..7591801f8ac9 100644
--- a/products/conversations/backend/ai/human_outcome.py
+++ b/products/conversations/backend/ai/human_outcome.py
@@ -72,6 +72,7 @@ def record_human_outcome(*, team_id: int, ticket_id: str, draft_message_id: str,
latest_ai_draft_id = (
_ticket_comments(team_id=team_id, ticket_id=ticket_id)
.filter(item_context__author_type="AI", item_context__is_private=True, deleted=False)
+ .filter(item_context__automated_update_key__isnull=True)
.order_by("-created_at", "-id")
.values_list("id", flat=True)
.first()
@@ -114,6 +115,7 @@ def maybe_record_human_outcome(*, team_id: int, ticket_id: str, comment_id: str,
# to the customer, so a later human reply is a follow-up, not adoption of a draft.
ai_note = (
comments.filter(item_context__author_type="AI", item_context__is_private=True, deleted=False)
+ .filter(item_context__automated_update_key__isnull=True)
.filter(before_this)
.order_by("-created_at", "-id")
.first()
diff --git a/products/conversations/backend/api/tests/test_ai_triage_api.py b/products/conversations/backend/api/tests/test_ai_triage_api.py
index edf529138bed..4f8d9bd9c2d8 100644
--- a/products/conversations/backend/api/tests/test_ai_triage_api.py
+++ b/products/conversations/backend/api/tests/test_ai_triage_api.py
@@ -89,6 +89,21 @@ def test_rejects_an_outcome_for_an_old_draft(self) -> None:
self.ticket.refresh_from_db()
assert "human_outcome" not in self.ticket.ai_triage
+ def test_automated_update_does_not_replace_the_latest_ai_draft(self) -> None:
+ Comment.objects.create(
+ team=self.team,
+ scope="conversations_ticket",
+ item_id=str(self.ticket.id),
+ content="Pull request update",
+ item_context={"author_type": "AI", "is_private": True, "automated_update_key": "pr-123"},
+ )
+
+ response = self.client.post(self.url, self._payload("used"), format="json")
+
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ self.ticket.refresh_from_db()
+ assert self.ticket.ai_triage["human_outcome"] == "used"
+
class TestAiTriageSourcesAPI(APIBaseTest):
def setUp(self) -> None:
diff --git a/products/conversations/backend/api/tests/test_signals.py b/products/conversations/backend/api/tests/test_signals.py
index 7a00638d6ad1..a36a1f9c4833 100644
--- a/products/conversations/backend/api/tests/test_signals.py
+++ b/products/conversations/backend/api/tests/test_signals.py
@@ -150,6 +150,15 @@ def test_first_public_human_reply_records_human_outcome(self, mock_on_commit):
self.ticket.refresh_from_db()
assert self.ticket.ai_triage["human_outcome"] == "used"
+ def test_automated_update_does_not_replace_draft_for_human_outcome(self, mock_on_commit):
+ draft = "Add the snippet to the head of every page."
+ self._create_ai_message(draft)
+ self._create_ai_message("Pull request update", item_context={"automated_update_key": "pr-123"})
+ self._create_team_message(draft)
+
+ self.ticket.refresh_from_db()
+ assert self.ticket.ai_triage["human_outcome"] == "used"
+
def test_used_draft_then_edited_send_upgrades_human_outcome(self, mock_on_commit):
draft = "Add the snippet to the head of every page, then reload to send a pageview."
ai_draft = self._create_ai_message(draft)
diff --git a/products/conversations/backend/api/tests/test_tickets.py b/products/conversations/backend/api/tests/test_tickets.py
index 11d1188ae08a..e1ec39b8c269 100644
--- a/products/conversations/backend/api/tests/test_tickets.py
+++ b/products/conversations/backend/api/tests/test_tickets.py
@@ -3480,6 +3480,25 @@ def test_ai_feedback_rejects_invalid_rating(self, mock_on_commit):
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
+ @patch("products.conversations.backend.api.tickets.posthoganalytics.capture")
+ def test_ai_feedback_rejects_automated_update(self, mock_capture, mock_on_commit):
+ update = Comment.objects.create(
+ team=self.team,
+ scope="conversations_ticket",
+ item_id=str(self.ticket.id),
+ content="Pull request update",
+ item_context={"author_type": "AI", "is_private": True, "automated_update_key": "pr-123"},
+ )
+
+ response = self.client.post(
+ self.url,
+ {"message_id": str(update.id), "rating": "good"},
+ format="json",
+ )
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+ mock_capture.assert_not_called()
+
class TestTicketAccessControl(APIBaseTest):
"""Resource- and object-level access control for support tickets (the `ticket` RBAC resource)."""
diff --git a/products/conversations/backend/api/tickets.py b/products/conversations/backend/api/tickets.py
index 6cfd36907aa4..84b7b9316da5 100644
--- a/products/conversations/backend/api/tickets.py
+++ b/products/conversations/backend/api/tickets.py
@@ -1933,6 +1933,22 @@ def ai_feedback(self, request, *args, **kwargs):
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
+ try:
+ message_id = uuid.UUID(data["message_id"])
+ except ValueError:
+ message_id = None
+ if (
+ message_id
+ and Comment.objects.filter(
+ id=message_id,
+ team_id=self.team_id,
+ scope="conversations_ticket",
+ item_id=str(ticket.id),
+ item_context__automated_update_key__isnull=False,
+ ).exists()
+ ):
+ raise ValidationError({"message_id": "Automated updates cannot receive AI feedback."})
+
ai_triage = ticket.ai_triage or {}
trace_id = ai_triage.get("ai_trace_id")
distinct_id = f"reviewer:{request.user.distinct_id}"
diff --git a/products/conversations/backend/facade/api.py b/products/conversations/backend/facade/api.py
index ed31ad9a2920..b63e1b47e2e3 100644
--- a/products/conversations/backend/facade/api.py
+++ b/products/conversations/backend/facade/api.py
@@ -59,10 +59,11 @@
Status,
Ticket,
)
-from products.conversations.backend.models.constants import WORKFLOW_AUTHOR_TYPE
+from products.conversations.backend.models.constants import WORKFLOW_AUTHOR_TYPE, Channel
from products.conversations.backend.services.messages import public_human_ticket_replies
-from products.conversations.backend.slack import get_slack_client
+from products.conversations.backend.slack import _is_internal_channel, get_slack_client
from products.conversations.backend.support_slack import (
+ automated_update_channel_allowed,
get_support_slack_bot_token,
supporthog_lacks_custom_identity_scope,
)
@@ -441,6 +442,59 @@ def post_ticket_internal_note(team_id: int, ticket_id: str, content: str, *, ded
return str(comment.id)
+def post_ticket_automated_update(
+ team_id: int, ticket_id: str, *, public_content: str, private_content: str, dedupe_key: str
+) -> str | None:
+ source_ticket = Ticket.objects.filter(team_id=team_id, id=ticket_id).select_related("team").first()
+ if source_ticket is None:
+ return None
+ checked_channel = source_ticket.slack_channel_id
+ send_to_slack = False
+ if (
+ source_ticket.channel_source == Channel.SLACK
+ and checked_channel
+ and source_ticket.slack_thread_ts
+ and automated_update_channel_allowed(source_ticket.team, checked_channel)
+ ):
+ try:
+ send_to_slack = _is_internal_channel(get_slack_client(source_ticket.team), checked_channel)
+ except Exception:
+ logger.exception("Failed to check support Slack channel before automated update", ticket_id=ticket_id)
+
+ with transaction.atomic():
+ ticket = Ticket.objects.select_for_update().filter(team_id=team_id, id=ticket_id).first()
+ if ticket is None:
+ return None
+ if Comment.objects.filter(
+ team_id=team_id,
+ scope="conversations_ticket",
+ item_id=ticket_id,
+ item_context__automated_update_key=dedupe_key,
+ deleted=False,
+ ).exists():
+ return None
+ send_to_slack = bool(
+ send_to_slack
+ and ticket.channel_source == Channel.SLACK
+ and ticket.slack_channel_id == checked_channel
+ and ticket.slack_thread_ts == source_ticket.slack_thread_ts
+ )
+ comment = Comment.objects.create(
+ team_id=team_id,
+ scope="conversations_ticket",
+ item_id=ticket_id,
+ content=public_content if send_to_slack else private_content,
+ item_context={
+ "author_type": "AI",
+ "is_private": not send_to_slack,
+ "automated_update_key": dedupe_key,
+ "internal_channel_only": send_to_slack,
+ **({"internal_note_key": dedupe_key} if not send_to_slack else {}),
+ },
+ )
+ return str(comment.id)
+
+
def trigger_immediate_channel_summary(
*,
team_id: int,
diff --git a/products/conversations/backend/models/constants.py b/products/conversations/backend/models/constants.py
index 63fb9e56876c..76ce60614837 100644
--- a/products/conversations/backend/models/constants.py
+++ b/products/conversations/backend/models/constants.py
@@ -1,5 +1,7 @@
from django.db import models
+AUTOMATED_UPDATE_SLACK_CHANNEL_IDS = "automated_update_slack_channel_ids"
+
class Channel(models.TextChoices):
WIDGET = "widget", "Widget"
diff --git a/products/conversations/backend/services/delivery.py b/products/conversations/backend/services/delivery.py
index dd23a3f7d908..e9af5a3e795d 100644
--- a/products/conversations/backend/services/delivery.py
+++ b/products/conversations/backend/services/delivery.py
@@ -220,7 +220,7 @@ def _body_snapshot(
include_images=False,
organization_id=team.organization_id,
)
- return {
+ snapshot = {
"text": slack_text,
"blocks": slack_blocks,
"author_name": author.name,
@@ -228,6 +228,9 @@ def _body_snapshot(
"media_team_id": media_team_id,
"images": _image_refs(comment.rich_content if isinstance(comment.rich_content, dict) else None),
}
+ if isinstance(comment.item_context, dict) and comment.item_context.get("internal_channel_only") is True:
+ snapshot["internal_channel_only"] = True
+ return snapshot
def _enqueue_failed_slack_body(
diff --git a/products/conversations/backend/signals.py b/products/conversations/backend/signals.py
index fb1519ae0201..1da8304dcf17 100644
--- a/products/conversations/backend/signals.py
+++ b/products/conversations/backend/signals.py
@@ -143,6 +143,14 @@ def update_ticket_on_message(sender, instance: Comment, created: bool, **kwargs)
if not created:
return
+ if (
+ instance.created_by_id is None
+ and isinstance(instance.item_context, dict)
+ and instance.item_context.get("author_type") == "AI"
+ and instance.item_context.get("automated_update_key")
+ ):
+ return
+
# Capture values for closure (avoid referencing instance in deferred callback)
team_id = instance.team_id
item_id = instance.item_id
diff --git a/products/conversations/backend/slack.py b/products/conversations/backend/slack.py
index 02e233beaccd..69636db650ff 100644
--- a/products/conversations/backend/slack.py
+++ b/products/conversations/backend/slack.py
@@ -1665,7 +1665,7 @@ def ticket_unfurl(ticket: "Ticket", team: Team) -> dict:
}
-def _is_internal_channel(client: WebClient, channel: str) -> bool:
+def _is_internal_channel(client: WebClient, channel: str, *, raise_on_error: bool = False) -> bool:
"""Whether `channel` is an ordinary channel of this workspace alone.
Fails closed, twice over: a channel we cannot read is one we cannot prove is internal, and
@@ -1684,6 +1684,8 @@ def _is_internal_channel(client: WebClient, channel: str) -> bool:
response = client.conversations_info(channel=channel)
except Exception:
logger.warning("slack_support_unfurl_channel_lookup_failed", slack_channel_id=channel)
+ if raise_on_error:
+ raise
return False
info = response.get("channel") or {}
if any(info.get(flag) for flag in _NON_INTERNAL_CHANNEL_FLAGS):
diff --git a/products/conversations/backend/support_slack.py b/products/conversations/backend/support_slack.py
index f3ede863a48e..19fd8ba4d568 100644
--- a/products/conversations/backend/support_slack.py
+++ b/products/conversations/backend/support_slack.py
@@ -11,6 +11,7 @@
from posthog.models.utils import mask_key_value
from products.conversations.backend.models import TeamConversationsSlackConfig
+from products.conversations.backend.models.constants import AUTOMATED_UPDATE_SLACK_CHANNEL_IDS
if TYPE_CHECKING:
from posthog.models.team.team import Team
@@ -28,6 +29,13 @@
SUPPORT_SLACK_CUSTOM_IDENTITY_SCOPE = "chat:write.customize"
+def automated_update_channel_allowed(team: "Team", channel_id: str) -> bool:
+ project_team = team.parent_team or team
+ settings = project_team.conversations_settings
+ channel_ids = settings.get(AUTOMATED_UPDATE_SLACK_CHANNEL_IDS) if isinstance(settings, dict) else None
+ return isinstance(channel_ids, list) and channel_id in channel_ids
+
+
# Every SupportHog delivery is verified with the signing secret, so the read is cached and not made
# once per request. A rotation reaches each process within five minutes.
@cache_for(timedelta(minutes=5))
diff --git a/products/conversations/backend/tasks/slack.py b/products/conversations/backend/tasks/slack.py
index 84ffe3590cfe..f47f0f9ffa01 100644
--- a/products/conversations/backend/tasks/slack.py
+++ b/products/conversations/backend/tasks/slack.py
@@ -83,6 +83,7 @@
NudgeClassifierVerdict,
NudgeFunnelVerdict,
SlackConfirmationNeedsRetry,
+ _is_internal_channel,
capture_nudge_event,
capture_support_event,
create_ticket_from_confirmation,
@@ -102,7 +103,11 @@
ticket_deep_link,
)
-from ..support_slack import SUPPORT_SLACK_ALLOWED_HOST_SUFFIXES, supporthog_missing_file_scopes
+from ..support_slack import (
+ SUPPORT_SLACK_ALLOWED_HOST_SUFFIXES,
+ automated_update_channel_allowed,
+ supporthog_missing_file_scopes,
+)
logger = structlog.get_logger(__name__)
SUPPORTHOG_EVENT_IDEMPOTENCY_TTL_SECONDS = 6 * 60
@@ -846,6 +851,9 @@ def _post_slack_body(
"text": slack_text,
"username": sender.username,
}
+ if payload.get("internal_channel_only") is True:
+ message_kwargs["unfurl_links"] = False
+ message_kwargs["unfurl_media"] = False
if client_msg_id:
message_kwargs["client_msg_id"] = client_msg_id
if sender.icon_url:
@@ -928,6 +936,34 @@ def _load_slack_delivery_runtime(claim: DeliveryClaim) -> SlackDeliveryRuntime |
def _deliver_slack_body(claim: DeliveryClaim, runtime: SlackDeliveryRuntime) -> None:
+ channel = str(runtime.route.get("channel") or "")
+ if runtime.payload.get("internal_channel_only") is True and not automated_update_channel_allowed(
+ runtime.team, channel
+ ):
+ _fail_claimed_delivery_part(
+ claim,
+ error_code="channel_not_allowlisted",
+ error="The Slack channel is not approved for automated updates",
+ )
+ return
+ if runtime.payload.get("internal_channel_only") is True:
+ try:
+ is_internal = _is_internal_channel(runtime.client, channel, raise_on_error=True)
+ except Exception as exc:
+ _retry_delivery_claim(
+ claim,
+ error_code="channel_lookup_failed",
+ error="Could not check whether the Slack channel is internal",
+ retry_after_seconds=_retry_after_seconds(exc),
+ )
+ return
+ if not is_internal:
+ _fail_claimed_delivery_part(
+ claim,
+ error_code="channel_not_internal",
+ error="The Slack channel is not confirmed as internal",
+ )
+ return
ts = _post_slack_body(
client=runtime.client,
team=runtime.team,
diff --git a/products/conversations/backend/temporal/coordinator.py b/products/conversations/backend/temporal/coordinator.py
index 7e2cf93d2f9a..bbb8442b3285 100644
--- a/products/conversations/backend/temporal/coordinator.py
+++ b/products/conversations/backend/temporal/coordinator.py
@@ -208,6 +208,7 @@ def _collect_eligible(lookback_minutes: int = TICKET_LOOKBACK_MINUTES) -> list[E
team_id=team_id,
scope="conversations_ticket",
item_id__in=ticket_ids,
+ item_context__automated_update_key__isnull=True,
).values_list("item_id", "item_context__author_type", "created_at"):
if author_type == "customer":
prev = latest_customer_msg.get(item_id)
diff --git a/products/conversations/backend/temporal/tests/test_coordinator.py b/products/conversations/backend/temporal/tests/test_coordinator.py
index 37eb3518c48e..256e8cde9038 100644
--- a/products/conversations/backend/temporal/tests/test_coordinator.py
+++ b/products/conversations/backend/temporal/tests/test_coordinator.py
@@ -406,7 +406,7 @@ def _team_and_ticket(self, *, status: str = "pending", awaiting: bool = True):
ticket.refresh_from_db()
return team, ticket
- def _comment(self, team, ticket, *, author_type: str, minutes_ago: int):
+ def _comment(self, team, ticket, *, author_type: str, minutes_ago: int, automated_update_key: str | None = None):
from posthog.models.comment import Comment
created = timezone.now() - timedelta(minutes=minutes_ago)
@@ -415,7 +415,12 @@ def _comment(self, team, ticket, *, author_type: str, minutes_ago: int):
scope="conversations_ticket",
item_id=str(ticket.id),
content=author_type,
- item_context={"author_type": author_type, "is_private": False, "persist_as": "clarification"},
+ item_context={
+ "author_type": author_type,
+ "is_private": False,
+ "persist_as": "clarification",
+ **({"automated_update_key": automated_update_key} if automated_update_key else {}),
+ },
)
Comment.objects.filter(id=comment.id).update(created_at=created)
from products.conversations.backend.models.ticket import Ticket as TicketModel
@@ -424,6 +429,23 @@ def _comment(self, team, ticket, *, author_type: str, minutes_ago: int):
ticket.refresh_from_db()
return comment
+ @pytest.mark.django_db
+ @patch(f"{COORD_MODULE}._is_master_flag_enabled", return_value=True)
+ def test_automated_pr_update_does_not_answer_a_customer_followup(self, mock_master_flag):
+ team, ticket = self._team_and_ticket()
+ self._comment(team, ticket, author_type="customer", minutes_ago=10)
+ self._comment(team, ticket, author_type="AI", minutes_ago=5)
+ self._comment(team, ticket, author_type="customer", minutes_ago=3)
+ self._comment(
+ team,
+ ticket,
+ author_type="AI",
+ minutes_ago=1,
+ automated_update_key="signals_pr:report:pr",
+ )
+
+ assert [item.ticket_id for item in _collect_eligible()] == [str(ticket.id)]
+
@parameterized.expand(
[
(
diff --git a/products/conversations/backend/tests/test_slack_delivery.py b/products/conversations/backend/tests/test_slack_delivery.py
index 854148d17133..fb3b3ee364d8 100644
--- a/products/conversations/backend/tests/test_slack_delivery.py
+++ b/products/conversations/backend/tests/test_slack_delivery.py
@@ -99,6 +99,108 @@ def _create_reply(self, content: str = "Support reply") -> Comment:
def _part(self) -> ConversationDeliveryPart:
return ConversationDeliveryPart.objects.unscoped().select_related("delivery").get()
+ @parameterized.expand(
+ [
+ ("shared", {"is_channel": True, "is_ext_shared": True}, True, False, "channel_not_internal"),
+ ("internal", {"is_channel": True}, True, True, ""),
+ ("approval_removed", {"is_channel": True}, False, False, "channel_not_allowlisted"),
+ ]
+ )
+ @patch("products.conversations.backend.tasks.slack.get_slack_client")
+ def test_automated_update_checks_channel_before_delivery(
+ self,
+ _name: str,
+ channel_info: dict,
+ approved: bool,
+ expected_sent: bool,
+ expected_error: str,
+ mock_get_client: MagicMock,
+ ) -> None:
+ self.team.conversations_settings = {
+ **self.team.conversations_settings,
+ "automated_update_slack_channel_ids": ["C123"] if approved else [],
+ }
+ self.team.save(update_fields=["conversations_settings"])
+ Comment.objects.create(
+ team=self.team,
+ scope="conversations_ticket",
+ item_id=str(self.ticket.id),
+ content="[PostHog bot] Pull request opened",
+ item_context={
+ "author_type": "AI",
+ "is_private": False,
+ "automated_update_key": "signals_pr:report:pr",
+ "internal_channel_only": True,
+ },
+ )
+ part = self._part()
+ payload = part.payload
+ assert isinstance(payload, dict)
+ assert payload["internal_channel_only"] is True
+ client = MagicMock()
+ client.conversations_info.return_value = {"channel": channel_info}
+ client.chat_postMessage.return_value = {"ts": "1700.1"}
+ mock_get_client.return_value = client
+
+ process_slack_delivery_part(str(part.id))
+
+ part.refresh_from_db()
+ if expected_sent:
+ assert part.status == ConversationDeliveryPart.Status.ACCEPTED
+ kwargs = client.chat_postMessage.call_args.kwargs
+ assert kwargs["unfurl_links"] is False
+ assert kwargs["unfurl_media"] is False
+ else:
+ assert part.status == ConversationDeliveryPart.Status.FAILED
+ assert part.last_error_code == expected_error
+ client.chat_postMessage.assert_not_called()
+
+ @parameterized.expand(
+ [
+ ("timeout", TimeoutError("Slack unavailable"), None),
+ ("rate_limited", _slack_api_error("ratelimited", retry_after="17", status_code=429), 17),
+ ]
+ )
+ @patch("products.conversations.backend.tasks.slack.get_slack_client")
+ def test_automated_update_retries_failed_channel_lookup(
+ self, _name: str, error: Exception, retry_after: int | None, mock_get_client: MagicMock
+ ) -> None:
+ self.team.conversations_settings = {
+ **self.team.conversations_settings,
+ "automated_update_slack_channel_ids": ["C123"],
+ }
+ self.team.save(update_fields=["conversations_settings"])
+ Comment.objects.create(
+ team=self.team,
+ scope="conversations_ticket",
+ item_id=str(self.ticket.id),
+ content="[PostHog bot] Pull request opened",
+ item_context={"author_type": "AI", "is_private": False, "internal_channel_only": True},
+ )
+ part = self._part()
+ client = MagicMock()
+ client.conversations_info.side_effect = [error, {"channel": {"is_channel": True}}]
+ client.chat_postMessage.return_value = {"ts": "1700.1"}
+ mock_get_client.return_value = client
+
+ with time_machine.travel("2026-09-21 12:00:00", tick=False):
+ ConversationDeliveryPart.objects.unscoped().filter(id=part.id).update(due_at=timezone.now())
+ process_slack_delivery_part(str(part.id))
+ part.refresh_from_db()
+ assert part.status == ConversationDeliveryPart.Status.PENDING
+ assert part.last_error_code == "channel_lookup_failed"
+ assert part.due_at > timezone.now()
+ if retry_after is not None:
+ assert part.due_at == timezone.now() + timedelta(seconds=retry_after)
+ client.chat_postMessage.assert_not_called()
+
+ ConversationDeliveryPart.objects.unscoped().filter(id=part.id).update(due_at=timezone.now())
+ process_slack_delivery_part(str(part.id))
+
+ part.refresh_from_db()
+ assert part.status == ConversationDeliveryPart.Status.ACCEPTED
+ client.chat_postMessage.assert_called_once()
+
def test_enqueue_failure_rolls_back_the_comment(self) -> None:
with (
self.assertRaises(RuntimeError),
diff --git a/products/conversations/frontend/components/Chat/aiDraftAction.test.ts b/products/conversations/frontend/components/Chat/aiDraftAction.test.ts
index b1bb5faf15bd..fcdc76abfb63 100644
--- a/products/conversations/frontend/components/Chat/aiDraftAction.test.ts
+++ b/products/conversations/frontend/components/Chat/aiDraftAction.test.ts
@@ -21,6 +21,7 @@ describe('aiDraftAction', () => {
['public auto-sent reply', { isPrivate: false, persistAs: 'reply' as const }, null],
['public clarifying question', { isPrivate: false, persistAs: 'clarification' as const }, null],
['human private note', { authorType: 'human' as const }, null],
+ ['automated private note', { automatedUpdate: true }, null],
])('%s', (_name, overrides, expected) => {
expect(aiDraftAction({ ...base, ...overrides })).toBe(expected)
})
diff --git a/products/conversations/frontend/components/Chat/aiDraftAction.ts b/products/conversations/frontend/components/Chat/aiDraftAction.ts
index 62581edc392f..b96e43985dc2 100644
--- a/products/conversations/frontend/components/Chat/aiDraftAction.ts
+++ b/products/conversations/frontend/components/Chat/aiDraftAction.ts
@@ -18,9 +18,9 @@ export function firstClarifyingQuestion(questions: unknown): string | null {
}
export function aiDraftAction(
- message: Pick
+ message: Pick
): AiDraftActionKind | null {
- if (message.authorType !== 'AI' || !message.isPrivate) {
+ if (message.authorType !== 'AI' || !message.isPrivate || message.automatedUpdate) {
return null
}
if (message.persistAs === 'clarification') {
diff --git a/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.test.ts b/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.test.ts
index bf8d8e5d32d3..360f7844f140 100644
--- a/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.test.ts
+++ b/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.test.ts
@@ -164,6 +164,19 @@ describe('supportTicketSceneLogic ai reply feedback', () => {
})
})
+ it('keeps feedback on the AI reply after an automated update', () => {
+ logic.actions.setMessages([
+ makeAiComment('msg-ai-1', false),
+ {
+ ...makeAiComment('pr-update', false),
+ created_at: '2026-01-01T00:01:00Z',
+ item_context: { author_type: 'AI', is_private: false, automated_update_key: 'pr-123' },
+ } as unknown as CommentType,
+ ])
+
+ expect(logic.values.latestAiMessage?.id).toBe('msg-ai-1')
+ })
+
it('calls backend relay on good feedback', async () => {
await expectLogic(logic, () => {
logic.actions.submitAiReplyFeedback('msg-ai-1', 'good')
@@ -348,6 +361,19 @@ describe('supportTicketSceneLogic chatMessages mapping', () => {
expect(logic.values.latestAiDraftId).toBe('new-draft')
})
+ it('shows automated private notes without treating them as AI drafts', () => {
+ featureFlagLogic.actions.setFeatureFlags([], { [FEATURE_FLAGS.PRODUCT_SUPPORT_AI_NOTES]: false })
+ logic.actions.setMessages([
+ {
+ ...makeAiComment('pr-update'),
+ item_context: { author_type: 'AI', is_private: true, automated_update_key: 'pr-123' },
+ } as unknown as CommentType,
+ ])
+
+ expect(logic.values.chatMessages).toEqual([expect.objectContaining({ id: 'pr-update', automatedUpdate: true })])
+ expect(logic.values.latestAiDraftId).toBeNull()
+ })
+
it('selects widget delivery statuses for public team messages', () => {
logic.actions.setTicket({ ...makeTicket(), unread_customer_count: 1 })
logic.actions.setMessages([
diff --git a/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.ts b/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.ts
index c39b85fa48b6..149a4106f1ff 100644
--- a/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.ts
+++ b/products/conversations/frontend/scenes/ticket/supportTicketSceneLogic.ts
@@ -281,7 +281,11 @@ export function getEmailReplyBlockedReason(
* make the thread misrepresent the conversation.
*/
export function isAiPrivateNote(message: Pick): boolean {
- return message.item_context?.author_type === 'AI' && message.item_context?.is_private === true
+ return (
+ message.item_context?.author_type === 'AI' &&
+ message.item_context?.is_private === true &&
+ !message.item_context?.automated_update_key
+ )
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
@@ -1226,6 +1230,7 @@ export const supportTicketSceneLogic = kea([
createdBy: message.created_by,
createdAt: message.created_at,
isPrivate: message.item_context?.is_private || false,
+ automatedUpdate: !!message.item_context?.automated_update_key,
version: message.version,
emailDeliveryStatus: message.item_context?.email_delivery_status,
fromZendesk: message.item_context?.from_zendesk === true,
@@ -1301,7 +1306,7 @@ export const supportTicketSceneLogic = kea([
(s) => [s.chatMessages],
(chatMessages: ChatMessage[]): ChatMessage | null => {
for (let i = chatMessages.length - 1; i >= 0; i--) {
- if (chatMessages[i].authorType === 'AI') {
+ if (chatMessages[i].authorType === 'AI' && !chatMessages[i].automatedUpdate) {
return chatMessages[i]
}
}
diff --git a/products/conversations/frontend/types.ts b/products/conversations/frontend/types.ts
index 9c0eb580fdc2..30cf45427af8 100644
--- a/products/conversations/frontend/types.ts
+++ b/products/conversations/frontend/types.ts
@@ -234,6 +234,7 @@ export interface ChatMessage {
createdBy?: MessageAuthor | null
createdAt: string
isPrivate?: boolean
+ automatedUpdate?: boolean
/** Edit count from the comment row. 0 means never edited. */
version?: number
emailDeliveryStatus?: EmailDeliveryStatus
diff --git a/products/data_warehouse/backend/logic/external_data_source/notifications.py b/products/data_warehouse/backend/logic/external_data_source/notifications.py
index 1be8f3e3a738..98af24cc1c63 100644
--- a/products/data_warehouse/backend/logic/external_data_source/notifications.py
+++ b/products/data_warehouse/backend/logic/external_data_source/notifications.py
@@ -24,13 +24,6 @@
# notified — the email communicates them in aggregate via the "+N more" line.
MAX_SCHEMAS_PER_DIGEST_EMAIL = 30
-# Re-notify a schema that stays failed this long after its last email. A paused
-# schema never runs again, so it produces no newer failed job to re-trigger the
-# digest — without this it gets exactly one email, then silence for weeks while
-# its data goes stale. The catch-up sweep runs daily, so it picks the schema up
-# the first day past this age and stamps a fresh time, spacing reminders evenly.
-RENOTIFY_STILL_FAILING_AFTER = dt.timedelta(days=7)
-
# A schema the user switched off is not a failure to chase: it will not run again, and nobody
# asked it to. Its last status stays Failed for the syncs UI, so the digest cannot read status
# alone. `auto_disabled_at` marks the other way syncing stops, where PostHog halted the schema
@@ -40,31 +33,22 @@
SCHEMA_STOPPED_BY_USER = Q(should_sync=False, auto_disabled_at__isnull=True)
-def get_team_ids_with_recent_sync_failures(
- lookback: dt.timedelta = dt.timedelta(hours=26),
- renotify_after: dt.timedelta = RENOTIFY_STILL_FAILING_AFTER,
-) -> list[int]:
+def get_team_ids_with_recent_sync_failures(lookback: dt.timedelta = dt.timedelta(hours=26)) -> list[int]:
"""Teams with still-failing schemas that need a digest.
- Powers the daily catch-up digest. It flushes two groups:
-
- - Failures that the one-email-per-day block swallowed. A failure counts only
- if it is newer than the schema's `last_error_notified_at` stamp, so failures
- already covered by an earlier digest do not trigger a duplicate.
- - Schemas that stay failed but produce no newer failed run — a paused schema
- never syncs again. Their last email is older than `renotify_after`, so they
- get a reminder instead of silence.
-
- Schemas the user switched off are left out entirely, per `SCHEMA_STOPPED_BY_USER`.
+ Powers the daily catch-up digest. It flushes failures that the one-email-per-day
+ block swallowed. A failure counts only if it is newer than the schema's
+ `last_error_notified_at` stamp, so failures already covered by an earlier digest
+ do not trigger a duplicate. A paused schema never runs again, so after its first
+ digest it stays out until a new run fails. Schemas the user switched off are left
+ out entirely, per `SCHEMA_STOPPED_BY_USER`.
The lookback exceeds the 24h digest day on purpose: a failure just after the
10:00 UTC rollover, blocked because that digest day's email already went out,
is 24h15m+ old by the next catch-up run — a 24h lookback would drop it forever
for paused schemas. The stamp check above keeps the wider window duplicate-free.
"""
- now = dt.datetime.now(dt.UTC)
- cutoff = now - lookback
- renotify_cutoff = now - renotify_after
+ cutoff = dt.datetime.now(dt.UTC) - lookback
# Drive from the schema side: the jobs table grows with every sync run and has
# no index on (status, finished_at), so starting there would seq-scan it daily.
# Schemas are one row each, and their jobs are reachable via the schema_id FK index.
@@ -78,21 +62,18 @@ def get_team_ids_with_recent_sync_failures(
.exclude(source__deleted=True)
.exclude(SCHEMA_STOPPED_BY_USER)
.filter(status=ExternalDataSchemaStatus.FAILED)
- .filter(Exists(unnotified_failed_job) | Q(last_error_notified_at__lt=renotify_cutoff))
+ .filter(Exists(unnotified_failed_job))
.values_list("team_id", flat=True)
.distinct()
)
-def notify_external_data_sync_failures(
- team_id: int, renotify_after: dt.timedelta = RENOTIFY_STILL_FAILING_AFTER
-) -> None:
+def notify_external_data_sync_failures(team_id: int) -> None:
"""Email the team a digest of failing external data schemas that need a notification.
- A schema is listed when it was never notified, has a failed run newer than its last
- notification, or has stayed failed for longer than `renotify_after` since its last
- email. The last case reminds the team about a paused schema, which never runs again
- and so produces no newer failed run to re-trigger the digest on its own. Schemas of a
+ A schema is listed when it was never notified, or has a failed run newer than its last
+ notification. A paused schema never runs again, so it is listed once and then stays out
+ of later digests until someone turns syncing back on and a new run fails. Schemas of a
deleted source, and schemas the user switched off (see `SCHEMA_STOPPED_BY_USER`), are
excluded entirely. Runs inside the digest Celery task; exceptions are swallowed so a
notification problem never crash-loops the task. Throttling to one email per team per
@@ -100,7 +81,6 @@ def notify_external_data_sync_failures(
scheduling this for every failed job is safe.
"""
try:
- renotify_cutoff = dt.datetime.now(dt.UTC) - renotify_after
newer_failed_job = ExternalDataJob.objects.filter(
schema_id=OuterRef("id"),
status=ExternalDataJobStatus.FAILED,
@@ -111,11 +91,7 @@ def notify_external_data_sync_failures(
.exclude(source__deleted=True)
.exclude(SCHEMA_STOPPED_BY_USER)
.filter(team_id=team_id, status=ExternalDataSchemaStatus.FAILED)
- .filter(
- Q(last_error_notified_at__isnull=True)
- | Q(last_error_notified_at__lt=renotify_cutoff)
- | Exists(newer_failed_job)
- )
+ .filter(Q(last_error_notified_at__isnull=True) | Exists(newer_failed_job))
.select_related("source")
.order_by("name")
)
diff --git a/products/data_warehouse/backend/logic/external_data_source/test/test_notifications.py b/products/data_warehouse/backend/logic/external_data_source/test/test_notifications.py
index cf42bed816e0..f881fcf0756a 100644
--- a/products/data_warehouse/backend/logic/external_data_source/test/test_notifications.py
+++ b/products/data_warehouse/backend/logic/external_data_source/test/test_notifications.py
@@ -317,31 +317,38 @@ def test_already_notified_failure_renotifies_only_on_newer_run(self, run_offset,
assert mock_sender.called == expected_sent
@pytest.mark.parametrize(
- "notified_age,expected_sent",
+ "should_sync,auto_disabled,sync_type_config,notified_age",
[
- (dt.timedelta(days=8), True), # still failing past the window -> remind again
- (dt.timedelta(days=6), False), # notified recently, no new run -> stay silent
+ (False, True, {}, dt.timedelta(days=8)),
+ (False, True, {}, dt.timedelta(days=90)),
+ (True, False, {"cdc_broken": {"reason": "auto_dropped_critical_lag"}}, dt.timedelta(days=8)),
],
)
- def test_renotifies_still_failing_schema_without_a_newer_run(self, notified_age, expected_sent):
- # A paused schema never runs again, so it produces no newer failed job. It must
- # still get a reminder once its last email ages past the re-notify window.
+ def test_paused_schema_is_listed_only_once(self, should_sync, auto_disabled, sync_type_config, notified_age):
+ # A paused schema never runs again, so once a digest has listed it, later digests stay
+ # silent about it however long it stays failed.
team, source = _create_team_and_source()
- ExternalDataSchema.objects.create(
+ notified_at = dt.datetime.now(dt.UTC) - notified_age
+ schema = ExternalDataSchema.objects.create(
name="Charge",
team=team,
source=source,
status=ExternalDataSchema.Status.FAILED,
- should_sync=False,
- auto_disabled_at=dt.datetime.now(dt.UTC) - notified_age,
+ should_sync=should_sync,
+ auto_disabled_at=notified_at if auto_disabled else None,
+ sync_type_config=sync_type_config,
latest_error="Invalid API key",
- last_error_notified_at=dt.datetime.now(dt.UTC) - notified_age,
+ last_error_notified_at=notified_at,
+ )
+ job = ExternalDataJob.objects.create(
+ team=team, pipeline=source, schema=schema, status=ExternalDataJob.Status.FAILED
)
+ ExternalDataJob.objects.filter(id=job.id).update(finished_at=notified_at - dt.timedelta(minutes=5))
with patch(SENDER_PATH) as mock_sender:
notify_external_data_sync_failures(team.pk)
- assert mock_sender.called == expected_sent
+ mock_sender.assert_not_called()
class TestGetTeamIdsWithRecentSyncFailures:
@@ -436,19 +443,19 @@ def test_includes_failure_blocked_just_after_digest_rollover(self):
assert get_team_ids_with_recent_sync_failures() == [team.pk]
- def test_includes_still_failing_schema_past_the_renotify_window(self):
- # PostHog halted this schema itself, so it never runs again. No recent failed run
- # (30h stale) and the last email is 8 days old: the team would drop out forever
- # without the re-notify path.
- team = self._create_schema_with_job(
+ @pytest.mark.parametrize("notified_age", [dt.timedelta(days=8), dt.timedelta(days=90)])
+ def test_excludes_paused_schema_already_notified(self, notified_age):
+ # PostHog halted this schema itself, so it never runs again. Its one digest already went
+ # out, so the catch-up sweep must not pick it up again however old that email is.
+ self._create_schema_with_job(
schema_status=ExternalDataSchema.Status.FAILED,
- job_finished_at=dt.datetime.now(dt.UTC) - dt.timedelta(hours=30),
- last_error_notified_at=dt.datetime.now(dt.UTC) - dt.timedelta(days=8),
+ job_finished_at=dt.datetime.now(dt.UTC) - notified_age - dt.timedelta(minutes=5),
+ last_error_notified_at=dt.datetime.now(dt.UTC) - notified_age,
should_sync=False,
- auto_disabled_at=dt.datetime.now(dt.UTC) - dt.timedelta(days=9),
+ auto_disabled_at=dt.datetime.now(dt.UTC) - notified_age,
)
- assert get_team_ids_with_recent_sync_failures() == [team.pk]
+ assert get_team_ids_with_recent_sync_failures() == []
def test_only_returns_qualifying_teams(self):
qualifying = self._create_schema_with_job(
diff --git a/products/desktop/packages/core/src/inbox/reportActions.test.ts b/products/desktop/packages/core/src/inbox/reportActions.test.ts
index 91445921461c..a974f6e08b0a 100644
--- a/products/desktop/packages/core/src/inbox/reportActions.test.ts
+++ b/products/desktop/packages/core/src/inbox/reportActions.test.ts
@@ -263,6 +263,28 @@ describe("buildDiscussReportPrompt", () => {
});
describe("canCreateImplementationPr", () => {
+ it("does not offer a pull request for a scout self-improvement report without a repository", () => {
+ const report = {
+ id: "r",
+ status: "pending_input",
+ is_self_improvement_report: true,
+ repo_slug: null,
+ } as Partial as SignalReport;
+
+ expect(canCreateImplementationPr(report)).toBe(false);
+ });
+
+ it("offers a pull request for a scout self-improvement report with a repository", () => {
+ const report = {
+ id: "r",
+ status: "pending_input",
+ is_self_improvement_report: true,
+ repo_slug: "posthog/posthog",
+ } as Partial as SignalReport;
+
+ expect(canCreateImplementationPr(report)).toBe(true);
+ });
+
it("a merged PR no longer blocks a fresh attempt, but an open one does", () => {
const base = {
id: "r",
diff --git a/products/desktop/packages/core/src/inbox/reportActions.ts b/products/desktop/packages/core/src/inbox/reportActions.ts
index c382b66d2667..9e82001b5e4a 100644
--- a/products/desktop/packages/core/src/inbox/reportActions.ts
+++ b/products/desktop/packages/core/src/inbox/reportActions.ts
@@ -17,6 +17,9 @@ export function canCreateImplementationPr(
isTaskLookupPending?: boolean;
} = {},
): boolean {
+ if (report.is_self_improvement_report && !report.repo_slug) {
+ return false;
+ }
if (context.hasLiveImplementationTask || context.isTaskLookupPending) {
return false;
}
diff --git a/products/error_tracking/backend/facade/api.py b/products/error_tracking/backend/facade/api.py
index 43d9084f8dfe..590bf8f34ce4 100644
--- a/products/error_tracking/backend/facade/api.py
+++ b/products/error_tracking/backend/facade/api.py
@@ -191,6 +191,7 @@ def get_issue_basics(team_id: int, issue_id: UUID | str) -> contracts.ErrorTrack
description=issue.description,
status=issue.status,
severity=issue.severity,
+ created_at=issue.created_at,
first_seen=getattr(issue, "first_seen", None),
)
diff --git a/products/error_tracking/backend/facade/contracts.py b/products/error_tracking/backend/facade/contracts.py
index a6bd0dc2875f..01b6f505b313 100644
--- a/products/error_tracking/backend/facade/contracts.py
+++ b/products/error_tracking/backend/facade/contracts.py
@@ -313,6 +313,7 @@ class ErrorTrackingIssueBasics:
description: str | None
status: str
severity: str | None
+ created_at: datetime
# Earliest across all fingerprints, merged ones included.
first_seen: datetime | None
diff --git a/products/error_tracking/backend/logic/__init__.py b/products/error_tracking/backend/logic/__init__.py
index 2b1917bd46fc..9e3b9a03bcfc 100644
--- a/products/error_tracking/backend/logic/__init__.py
+++ b/products/error_tracking/backend/logic/__init__.py
@@ -99,7 +99,7 @@ def get_issue_basics(team_id: int, issue_id: UUID | str) -> ErrorTrackingIssue |
return (
ErrorTrackingIssue.objects.with_first_seen()
.filter(team_id=team_id, id=issue_id)
- .only("id", "name", "description", "status")
+ .only("id", "name", "description", "status", "severity", "created_at")
.first()
)
diff --git a/products/error_tracking/backend/logic/issue_summaries.py b/products/error_tracking/backend/logic/issue_summaries.py
index e32e0ab72474..cdaa0b7d4ec6 100644
--- a/products/error_tracking/backend/logic/issue_summaries.py
+++ b/products/error_tracking/backend/logic/issue_summaries.py
@@ -8,6 +8,8 @@
import json
import time
import hashlib
+from collections.abc import Iterator
+from contextlib import contextmanager
from datetime import datetime, timedelta
from enum import StrEnum
from uuid import UUID, uuid4
@@ -19,7 +21,7 @@
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam
from openai.types.shared_params import ResponseFormatJSONSchema
-from prometheus_client import Counter
+from prometheus_client import Counter, Histogram
from pydantic import ValidationError
from redis import Redis
from redis.exceptions import RedisError
@@ -37,8 +39,10 @@
from posthog.llm.gateway_client import build_openai_client, resolve_ai_gateway_config, team_distinct_id
from posthog.llm.semantic_enrichment import extract_json_object
from posthog.models import Team, User
+from posthog.ph_client import ph_background_capture
from posthog.redis import get_client
from posthog.token_bucket import BucketDecision, BucketUnavailable, consume, peek, refund
+from posthog.utils import get_instance_region
from products.access_control.backend.facade.api import (
get_restricted_properties_with_group_type_index_for_team,
@@ -104,6 +108,7 @@
ISSUE_SUMMARY_BUDGET_KEY_PREFIX = "error_tracking_issue_summary"
ISSUE_SUMMARY_LOCK_KEY_PREFIX = "error_tracking_issue_summary_lock"
ISSUE_SUMMARY_AI_PRODUCT = "error_tracking"
+ISSUE_SUMMARY_TASK_EVENT = "error_tracking_issue_summary_generation"
# The Go gateway keeps the prompt and the reply out of the AI observability project when this is set.
PRIVACY_MODE_HEADER = "X-PostHog-Privacy-Mode"
@@ -177,6 +182,36 @@ class IssueSummaryOutcome(StrEnum):
labelnames=("outcome",),
)
+ISSUE_SUMMARY_STAGE_SECONDS = Histogram(
+ "error_tracking_issue_summary_stage_seconds",
+ "Time spent in each issue summary generation stage",
+ labelnames=("stage",),
+ buckets=(0.05, 0.1, 0.25, 0.5, 1, 2, 4, 6, 8, 12, 20, 30, 60, 120),
+)
+
+
+class IssueSummaryTimings:
+ """Milliseconds per generation stage, keyed `_ms`. A stage that did not run has no entry.
+
+ Stages nest, so do not add them up: `total` contains every stage except `queue`, and `inputs` contains
+ `latest_event`, `first_event` and `aggregates`.
+ """
+
+ def __init__(self) -> None:
+ self.ms: dict[str, int] = {}
+
+ @contextmanager
+ def stage(self, name: str) -> Iterator[None]:
+ started = time.monotonic()
+ try:
+ yield
+ finally:
+ self.record(name, time.monotonic() - started)
+
+ def record(self, name: str, seconds: float) -> None:
+ self.ms[f"{name}_ms"] = round(seconds * 1000)
+ ISSUE_SUMMARY_STAGE_SECONDS.labels(stage=name).observe(seconds)
+
@frozen
class IssueSummaryResult:
@@ -289,7 +324,7 @@ def _publish_generation(team_id: int, issue_id: UUID | str) -> None:
transport_options={"socket_timeout": timeout, "socket_connect_timeout": timeout},
) as connection:
generate_error_tracking_issue_summary.apply_async(
- args=(team_id, str(issue_id)), connection=connection, retry=False
+ args=(team_id, str(issue_id)), kwargs={"queued_at": time.time()}, connection=connection, retry=False
)
@@ -312,13 +347,55 @@ def wait_for_issue_summary(
time.sleep(min(WAIT_POLL_INTERVAL_SECONDS, remaining))
-def get_or_generate_issue_summary(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
+def run_issue_summary_task(team_id: int, issue_id: UUID | str, queued_at: float | None) -> None:
+ """Generate in the background task, then record how long each stage took.
+
+ `queued_at` is the publisher's wall clock, so the queue time includes any clock skew between the hosts.
+ """
+ timings = IssueSummaryTimings()
+ if queued_at is not None:
+ timings.record("queue", max(time.time() - queued_at, 0))
+ try:
+ result = get_or_generate_issue_summary(team_id, issue_id, timings)
+ except Exception:
+ _report_task(team_id, issue_id, IssueSummaryOutcome.UNEXPECTED_ERROR, has_summary=False, timings=timings)
+ raise
+ _report_task(team_id, issue_id, result.outcome, has_summary=result.summary is not None, timings=timings)
+
+
+def _report_task(
+ team_id: int, issue_id: UUID | str, outcome: IssueSummaryOutcome, *, has_summary: bool, timings: IssueSummaryTimings
+) -> None:
+ # The background client sends from its own thread, so the worker takes the next task without waiting for a flush.
+ try:
+ ph_background_capture()(
+ distinct_id=team_distinct_id(team_id),
+ event=ISSUE_SUMMARY_TASK_EVENT,
+ properties={
+ "team_id": team_id,
+ "issue_id": str(issue_id),
+ # The client's `region` super property names the destination project and overwrites an event `region`.
+ "instance_region": get_instance_region(),
+ "outcome": outcome.value,
+ "has_summary": has_summary,
+ **timings.ms,
+ },
+ )
+ except Exception:
+ logger.warning("error_tracking_issue_summary_capture_failed", team_id=team_id, exc_info=True)
+
+
+def get_or_generate_issue_summary(
+ team_id: int, issue_id: UUID | str, timings: IssueSummaryTimings | None = None
+) -> IssueSummaryResult:
"""Return a stored summary, or generate one when none is stored or a stale one is due.
Generation runs at most once at a time per issue. A failure keeps the stored summary.
"""
+ timings = timings or IssueSummaryTimings()
try:
- result = _get_or_generate(team_id, issue_id)
+ with timings.stage("total"):
+ result = _get_or_generate(team_id, issue_id, timings)
except Exception:
ISSUE_SUMMARY_OUTCOMES.labels(outcome=IssueSummaryOutcome.UNEXPECTED_ERROR.value).inc()
raise
@@ -329,11 +406,12 @@ def get_or_generate_issue_summary(team_id: int, issue_id: UUID | str) -> IssueSu
issue_id=str(issue_id),
outcome=result.outcome.value,
has_summary=result.summary is not None,
+ **timings.ms,
)
return result
-def _get_or_generate(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
+def _get_or_generate(team_id: int, issue_id: UUID | str, timings: IssueSummaryTimings) -> IssueSummaryResult:
stored = get_issue_summary(team_id, issue_id)
if (blocked := generation_blocked(team_id, stored)) is not None:
return IssueSummaryResult(outcome=blocked, summary=stored)
@@ -342,7 +420,7 @@ def _get_or_generate(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
return IssueSummaryResult(outcome=held, summary=stored)
try:
- result = _generate_claimed(team_id, issue_id)
+ result = _generate_claimed(team_id, issue_id, timings)
except Exception:
_settle_claim(team_id, issue_id, claim, IssueSummaryOutcome.UNEXPECTED_ERROR)
raise
@@ -350,7 +428,7 @@ def _get_or_generate(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
return result
-def _generate_claimed(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
+def _generate_claimed(team_id: int, issue_id: UUID | str, timings: IssueSummaryTimings) -> IssueSummaryResult:
# Another worker can finish between the first read and the claim.
stored = get_issue_summary(team_id, issue_id)
if not regeneration_due(stored, timezone.now()):
@@ -364,7 +442,8 @@ def _generate_claimed(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
team = Team.objects.get(id=team_id)
try:
- inputs = build_issue_summary_inputs(team, issue_id)
+ with timings.stage("inputs"):
+ inputs = build_issue_summary_inputs(team, issue_id, timings)
except Exception as error:
capture_exception(error, {"team_id": team_id, "issue_id": str(issue_id)})
logger.warning("error_tracking_issue_summary_inputs_failed", team_id=team_id, issue_id=str(issue_id))
@@ -390,7 +469,8 @@ def _generate_claimed(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
return IssueSummaryResult(outcome=IssueSummaryOutcome.GATEWAY_UNCONFIGURED, summary=stored)
try:
- raw = _call_model(team_id, inputs.prompt)
+ with timings.stage("model"):
+ raw = _call_model(team_id, issue_id, inputs.prompt)
except Exception as error:
capture_exception(error, {"team_id": team_id, "issue_id": str(issue_id)})
logger.warning("error_tracking_issue_summary_model_failed", team_id=team_id, issue_id=str(issue_id))
@@ -401,7 +481,8 @@ def _generate_claimed(team_id: int, issue_id: UUID | str) -> IssueSummaryResult:
logger.warning("error_tracking_issue_summary_invalid_output", team_id=team_id, issue_id=str(issue_id))
return IssueSummaryResult(outcome=IssueSummaryOutcome.INVALID_OUTPUT, summary=stored)
- category = infer_issue_category(team_id, inputs.prompt)
+ with timings.stage("category"):
+ category = infer_issue_category(team_id, inputs.prompt)
generated_at = timezone.now()
try:
summary, _ = ErrorTrackingIssueSummary.objects.for_team(team_id).update_or_create(
@@ -485,7 +566,9 @@ def compute_input_hash(*, fingerprints: list[str], latest_release: str | None, o
)
-def build_issue_summary_inputs(team: Team, issue_id: UUID | str) -> IssueSummaryInputs | IssueSummaryOutcome:
+def build_issue_summary_inputs(
+ team: Team, issue_id: UUID | str, timings: IssueSummaryTimings
+) -> IssueSummaryInputs | IssueSummaryOutcome:
"""Build the prompt and the input hash, or say why the issue cannot be summarised."""
issue = ErrorTrackingIssue.objects.with_first_seen().filter(team_id=team.id, id=issue_id).first()
if issue is None:
@@ -494,12 +577,13 @@ def build_issue_summary_inputs(team: Team, issue_id: UUID | str) -> IssueSummary
now = timezone.now()
event_properties: dict[str, object] = {}
- for lookback_days in (ISSUE_SUMMARY_RECENT_EVENT_LOOKBACK_DAYS, ISSUE_SUMMARY_LATEST_EVENT_LOOKBACK_DAYS):
- event_properties = _exception_event_properties(
- team, issue_id, after=now - timedelta(days=lookback_days), before=now, latest=True
- )
- if event_properties:
- break
+ with timings.stage("latest_event"):
+ for lookback_days in (ISSUE_SUMMARY_RECENT_EVENT_LOOKBACK_DAYS, ISSUE_SUMMARY_LATEST_EVENT_LOOKBACK_DAYS):
+ event_properties = _exception_event_properties(
+ team, issue_id, after=now - timedelta(days=lookback_days), before=now, latest=True
+ )
+ if event_properties:
+ break
stack_trace = (
render_stacktrace(mark_in_app_frames(event_properties), ISSUE_SUMMARY_STACKTRACE_MAX_TOKENS)
if event_properties
@@ -510,15 +594,17 @@ def build_issue_summary_inputs(team: Team, issue_id: UUID | str) -> IssueSummary
first_event_properties: dict[str, object] = {}
if (window := first_event_window(first_seen)) is not None:
- first_event_properties = _exception_event_properties(
- team,
- issue_id,
- after=datetime.fromisoformat(window.after),
- before=datetime.fromisoformat(window.before),
- latest=False,
- )
+ with timings.stage("first_event"):
+ first_event_properties = _exception_event_properties(
+ team,
+ issue_id,
+ after=datetime.fromisoformat(window.after),
+ before=datetime.fromisoformat(window.before),
+ latest=False,
+ )
- aggregated_issue = _aggregated_issue(team, issue_id)
+ with timings.stage("aggregates"):
+ aggregated_issue = _aggregated_issue(team, issue_id)
impact = build_impact(aggregated_issue)
trend = build_trend(aggregated_issue, first_seen) or {}
latest_release = extract_release(event_properties).get("version")
@@ -665,11 +751,11 @@ def _aggregated_issue(team: Team, issue_id: UUID | str) -> dict[str, object]:
}
-def _call_model(team_id: int, prompt: str) -> str:
+def _call_model(team_id: int, issue_id: UUID | str, prompt: str) -> str:
client: OpenAI = build_openai_client(
"django",
ai_product=ISSUE_SUMMARY_AI_PRODUCT,
- properties={"team_id": str(team_id)},
+ properties={"team_id": str(team_id), "issue_id": str(issue_id)},
distinct_id=team_distinct_id(team_id),
)
messages: list[ChatCompletionMessageParam] = [
diff --git a/products/error_tracking/backend/presentation/views/query.py b/products/error_tracking/backend/presentation/views/query.py
index a5b7bcf3002a..891d4b2ac448 100644
--- a/products/error_tracking/backend/presentation/views/query.py
+++ b/products/error_tracking/backend/presentation/views/query.py
@@ -139,14 +139,27 @@ def summary_payload(summary: contracts.ErrorTrackingIssueSummary) -> dict[str, o
class ErrorTrackingQueryViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet):
scope_object = "error_tracking"
- def _report_issue_read(self, request: ValidatedRequest, issue_id: str, endpoint: str, **properties: object) -> None:
+ def _report_issue_read(
+ self,
+ request: ValidatedRequest,
+ issue_basics: contracts.ErrorTrackingIssueBasics,
+ endpoint: str,
+ **properties: object,
+ ) -> None:
# The frontend issue events see only the web app, so this is the one record of which issues
# MCP, CLI and API callers read. A capture failure must not fail the read.
+ issue_id = str(issue_basics.id)
try:
report_user_action(
cast(User, request.user),
"error_tracking_issue_read",
- {"issue_id": issue_id, "endpoint": endpoint, **properties},
+ {
+ "issue_id": issue_id,
+ "endpoint": endpoint,
+ "issue_created_at": issue_basics.created_at.isoformat(),
+ "issue_severity": issue_basics.severity or None,
+ **properties,
+ },
team=self.team,
organization=self.team.organization,
request=request,
@@ -264,6 +277,7 @@ def issue(self, request: ValidatedRequest, **kwargs: object) -> Response:
return self._issue_response(
request,
issue_id,
+ issue_basics,
payload,
started_at=started_at,
include_breakdown=include_breakdown,
@@ -307,6 +321,7 @@ def issue(self, request: ValidatedRequest, **kwargs: object) -> Response:
return self._issue_response(
request,
issue_id,
+ issue_basics,
payload,
started_at=started_at,
include_breakdown=include_breakdown,
@@ -317,6 +332,7 @@ def _issue_response(
self,
request: ValidatedRequest,
issue_id: str,
+ issue_basics: contracts.ErrorTrackingIssueBasics,
payload: dict[str, object],
*,
started_at: float,
@@ -331,7 +347,7 @@ def _issue_response(
payload["summary_status"] = summary_read.status.value
self._report_issue_read(
request,
- issue_id,
+ issue_basics,
"issue",
include_breakdown=include_breakdown,
include_sparkline=include_sparkline,
@@ -441,7 +457,8 @@ def issue_events(self, request: ValidatedRequest, **kwargs: object) -> Response:
issue_id = str(params["issueId"])
limit = cast(int, params.get("limit", 1))
offset = cast(int, params.get("offset", 0))
- if not facade_api.issue_exists_by_id(self.team.id, issue_id):
+ issue_basics = facade_api.get_issue_basics(self.team.id, issue_id)
+ if issue_basics is None:
return Response(status=status.HTTP_404_NOT_FOUND)
date_range = build_date_range(params.get("dateRange"))
requested_includes = params.get("include")
@@ -491,5 +508,5 @@ def issue_events(self, request: ValidatedRequest, **kwargs: object) -> Response:
payload: dict[str, object] = {"results": results, "hasMore": has_more, "limit": limit, "offset": offset}
if next_offset is not None:
payload["nextOffset"] = next_offset
- self._report_issue_read(request, issue_id, "issue_events")
+ self._report_issue_read(request, issue_basics, "issue_events")
return Response(payload)
diff --git a/products/error_tracking/backend/tasks/tasks.py b/products/error_tracking/backend/tasks/tasks.py
index 81414b1979c7..6d365136674d 100644
--- a/products/error_tracking/backend/tasks/tasks.py
+++ b/products/error_tracking/backend/tasks/tasks.py
@@ -94,8 +94,8 @@ def dispatch_error_tracking_alert_deliveries(team_id: int, notifications: list[d
max_retries=0,
)
@with_team_scope()
-def generate_error_tracking_issue_summary(team_id: int, issue_id: str) -> None:
+def generate_error_tracking_issue_summary(team_id: int, issue_id: str, queued_at: float | None = None) -> None:
# The generator imports the LLM client and HogQL, so keep them off every worker's boot path.
- from products.error_tracking.backend.logic.issue_summaries import get_or_generate_issue_summary # noqa: PLC0415
+ from products.error_tracking.backend.logic.issue_summaries import run_issue_summary_task # noqa: PLC0415
- get_or_generate_issue_summary(team_id, issue_id)
+ run_issue_summary_task(team_id, issue_id, queued_at)
diff --git a/products/error_tracking/backend/tests/api/test_query_api.py b/products/error_tracking/backend/tests/api/test_query_api.py
index dd62bf4ca4b6..52017e081b53 100644
--- a/products/error_tracking/backend/tests/api/test_query_api.py
+++ b/products/error_tracking/backend/tests/api/test_query_api.py
@@ -936,16 +936,36 @@ def test_issue_events_returns_404_for_foreign_issue(self) -> None:
(
"issue",
True,
+ ErrorTrackingIssue.Severity.HIGH,
{"includeSparkline": True},
- {"include_breakdown": False, "include_sparkline": True, "summary_status": "unavailable"},
+ {
+ "include_breakdown": False,
+ "include_sparkline": True,
+ "summary_status": "unavailable",
+ "issue_created_at": "2026-04-01T00:00:00+00:00",
+ "issue_severity": "high",
+ },
),
(
"issue",
False,
+ None,
{"includeSparkline": True},
- {"include_breakdown": False, "include_sparkline": True, "summary_status": "unavailable"},
+ {
+ "include_breakdown": False,
+ "include_sparkline": True,
+ "summary_status": "unavailable",
+ "issue_created_at": "2026-04-01T00:00:00+00:00",
+ "issue_severity": None,
+ },
+ ),
+ (
+ "issue_events",
+ True,
+ ErrorTrackingIssue.Severity.HIGH,
+ {},
+ {"issue_created_at": "2026-04-01T00:00:00+00:00", "issue_severity": "high"},
),
- ("issue_events", True, {}, {}),
]
)
@time_machine.travel("2026-04-24T12:00:00Z", tick=False)
@@ -953,10 +973,12 @@ def test_issue_reads_capture_an_event_for_existing_issues_only(
self,
endpoint: str,
has_exceptions: bool,
+ severity: str | None,
extra_body: dict[str, object],
expected_properties: dict[str, object],
) -> None:
- self.create_issue()
+ self.create_issue(severity=severity)
+ ErrorTrackingIssue.objects.filter(id=self.issue_id).update(created_at=datetime(2026, 4, 1, tzinfo=UTC))
if has_exceptions:
self.create_exception_event()
flush_persons_and_events()
diff --git a/products/error_tracking/backend/tests/test_issue_summaries.py b/products/error_tracking/backend/tests/test_issue_summaries.py
index ab15ae21d127..b577e7b96fb5 100644
--- a/products/error_tracking/backend/tests/test_issue_summaries.py
+++ b/products/error_tracking/backend/tests/test_issue_summaries.py
@@ -1,6 +1,7 @@
import json
import signal
import socket
+from contextlib import nullcontext
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -27,6 +28,7 @@
from products.error_tracking.backend.logic.issue_summaries import (
IssueSummaryInputs,
IssueSummaryOutcome,
+ IssueSummaryTimings,
build_issue_summary_inputs,
compute_input_hash,
get_or_generate_issue_summary,
@@ -130,7 +132,9 @@ def setUp(self) -> None:
self.decide = MagicMock(return_value=_category_result("own_code"))
self.enterContext(patch.object(team_flags, "feature_enabled_or_false", return_value=True))
self.enterContext(patch.object(issue_summaries, "resolve_ai_gateway_config", return_value=MagicMock()))
- self.enterContext(patch.object(issue_summaries, "build_openai_client", return_value=self.client_mock))
+ self.build_client = self.enterContext(
+ patch.object(issue_summaries, "build_openai_client", return_value=self.client_mock)
+ )
self.build_inputs = self.enterContext(
patch.object(issue_summaries, "build_issue_summary_inputs", side_effect=lambda *_: self.inputs)
)
@@ -179,6 +183,7 @@ def test_generates_and_stores_summary(
assert request["extra_headers"] == {"X-PostHog-Privacy-Mode": "true"}
assert request["response_format"]["json_schema"]["strict"] is True
assert self.decide.call_args.args[0].privacy_mode is True
+ assert self.build_client.call_args.kwargs["properties"]["issue_id"] == str(self.issue.id)
@parameterized.expand(
[
@@ -193,6 +198,33 @@ def test_generates_when_redis_is_down(self, _name: str, target: str, patch_kwarg
assert result.outcome == IssueSummaryOutcome.GENERATED
+ @parameterized.expand(
+ [
+ ("generated", None, IssueSummaryOutcome.GENERATED, {"queue_ms", "total_ms", "inputs_ms", "model_ms"}),
+ (
+ "unexpected_error",
+ OperationalError("db"),
+ IssueSummaryOutcome.UNEXPECTED_ERROR,
+ {"queue_ms", "total_ms"},
+ ),
+ ]
+ )
+ def test_task_reports_outcome_and_stage_timings(
+ self, _name: str, error: Exception | None, outcome: IssueSummaryOutcome, stages: set[str]
+ ) -> None:
+ capture = MagicMock()
+ self.enterContext(patch.object(issue_summaries, "ph_background_capture", return_value=capture))
+ if error is not None:
+ self.enterContext(patch.object(issue_summaries, "get_issue_summary", side_effect=error))
+
+ with time_machine.travel(NOW, tick=False), self.assertRaises(type(error)) if error else nullcontext():
+ issue_summaries.run_issue_summary_task(self.team.id, self.issue.id, NOW.timestamp() - 2)
+
+ properties = capture.call_args.kwargs["properties"]
+ assert properties["outcome"] == outcome.value
+ assert properties["queue_ms"] == 2000
+ assert stages <= {key for key in properties if key.endswith("_ms")}
+
@property
def _budget_key(self) -> str:
return f"{issue_summaries.ISSUE_SUMMARY_BUDGET_KEY_PREFIX}:{self.team.id}"
@@ -334,6 +366,7 @@ def test_merge_keeps_target_summary_and_deletes_source_summary(self) -> None:
def _start(self) -> tuple[bool, bool]:
with patch.object(issue_summaries.generate_error_tracking_issue_summary, "apply_async") as apply_async:
started = summaries_facade.start_issue_summary_generation(self.team.id, self.issue.id, user=self.user)
+ self.publish_call = apply_async.call_args
return started, apply_async.called
@parameterized.expand(
@@ -353,8 +386,13 @@ def test_start_generation_queues_only_when_it_can_generate(
if lock_value is not None:
issue_summaries.get_client().set(self._lock_key, lock_value, ex=60)
- with patch.object(team_flags, "feature_enabled_or_false", return_value=flag):
+ with (
+ patch.object(team_flags, "feature_enabled_or_false", return_value=flag),
+ time_machine.travel(NOW, tick=False),
+ ):
assert self._start() == (started, queued)
+ if queued:
+ assert self.publish_call.kwargs["kwargs"] == {"queued_at": NOW.timestamp()}
def test_start_generation_queues_when_redis_is_down(self) -> None:
with patch.object(issue_summaries, "get_client", side_effect=RedisError("down")):
@@ -585,9 +623,11 @@ def test_builds_prompt_from_latest_event_and_first_release(self) -> None:
)
flush_persons_and_events()
- inputs = build_issue_summary_inputs(self.team, issue.id)
+ timings = IssueSummaryTimings()
+ inputs = build_issue_summary_inputs(self.team, issue.id, timings)
assert isinstance(inputs, IssueSummaryInputs)
+ assert {"latest_event_ms", "first_event_ms", "aggregates_ms"} <= timings.ms.keys()
assert "Exception: TypeError: Cannot read properties of undefined" in inputs.prompt
assert "\ndispatch in react-dom.js\n[in-app] addToCart in cart.ts line 42" in inputs.prompt
assert "Handled: no" in inputs.prompt
@@ -601,4 +641,6 @@ def test_builds_prompt_from_latest_event_and_first_release(self) -> None:
def test_issue_without_exception_events_has_no_inputs(self) -> None:
issue = ErrorTrackingIssue.objects.create(team=self.team, name="TypeError")
- assert build_issue_summary_inputs(self.team, issue.id) == IssueSummaryOutcome.NO_EXCEPTION
+ assert (
+ build_issue_summary_inputs(self.team, issue.id, IssueSummaryTimings()) == IssueSummaryOutcome.NO_EXCEPTION
+ )
diff --git a/products/experiments/frontend/components/ExperimentMetaBar/ExperimentRemainingTime.tsx b/products/experiments/frontend/components/ExperimentMetaBar/ExperimentRemainingTime.tsx
index 2a9296bde038..6688e902d8dd 100644
--- a/products/experiments/frontend/components/ExperimentMetaBar/ExperimentRemainingTime.tsx
+++ b/products/experiments/frontend/components/ExperimentMetaBar/ExperimentRemainingTime.tsx
@@ -1,9 +1,13 @@
import { useValues } from 'kea'
-import { IconCheck, IconGear, IconHourglass } from '@posthog/icons'
+import { IconCheck, IconGear, IconHourglass, IconWarning } from '@posthog/icons'
import { LemonButton, Tooltip } from '@posthog/lemon-ui'
import { LemonProgressCircle } from 'lib/lemon-ui/LemonProgressCircle/LemonProgressCircle'
+import {
+ MINIMUM_DETECTABLE_EFFECT_MAX,
+ roundUpDetectableEffect,
+} from 'scenes/experiments/RunningTimeCalculator/calculations'
import { runningTimeLogic } from 'scenes/experiments/RunningTimeCalculator/runningTimeLogic'
import { Experiment } from '~/types'
@@ -17,9 +21,8 @@ export function ExperimentRemainingTime({
experiment: Experiment
onConfigure: () => void
}): JSX.Element {
- const { remainingDays, currentExposures, targetSampleSize, isComplete, isCalculating } = useValues(
- runningTimeLogic({ experiment })
- )
+ const { remainingDays, currentExposures, targetSampleSize, isComplete, isCalculating, outlook, targetEffect } =
+ useValues(runningTimeLogic({ experiment }))
const launched = isLaunched(experiment)
const showProgress = currentExposures !== null && targetSampleSize !== null
@@ -37,6 +40,28 @@ export function ExperimentRemainingTime({
Target reached
)
+ } else if (outlook?.isOutOfReach) {
+ // A countdown of months helps nobody, so the header says what the experiment can detect instead.
+ const reachable = roundUpDetectableEffect(outlook.reachableEffect)
+ const isBeyondMaximum = reachable > MINIMUM_DETECTABLE_EFFECT_MAX
+ content = (
+
+
+
+
+ {isBeyondMaximum
+ ? `Can detect only changes over ${MINIMUM_DETECTABLE_EFFECT_MAX}% within 4 weeks`
+ : `Can detect ${reachable.toLocaleString()}%+ changes within 4 weeks`}
+
+
+
+ )
} else {
content = (
diff --git a/products/experiments/frontend/modals/RunningTimeConfigModal/DetectableEffectTable.tsx b/products/experiments/frontend/modals/RunningTimeConfigModal/DetectableEffectTable.tsx
new file mode 100644
index 000000000000..dadb13b739d3
--- /dev/null
+++ b/products/experiments/frontend/modals/RunningTimeConfigModal/DetectableEffectTable.tsx
@@ -0,0 +1,137 @@
+import { useActions, useValues } from 'kea'
+
+import { IconCheck, IconWarning } from '@posthog/icons'
+import { LemonButton, LemonTable, LemonTag, Tooltip } from '@posthog/lemon-ui'
+
+import { dayjs } from 'lib/dayjs'
+import { Label } from 'lib/ui/Label/Label'
+import {
+ describeCheckpointDay,
+ describeUseButton,
+ formatDetectableEffect,
+} from 'scenes/experiments/RunningTimeCalculator/calculations'
+import { runningTimeLogic } from 'scenes/experiments/RunningTimeCalculator/runningTimeLogic'
+
+import { Experiment } from '~/types'
+
+export interface DetectableEffectTableProps {
+ experiment: Experiment
+}
+
+function formatDate(date: dayjs.Dayjs): string {
+ return date.format(date.isSame(dayjs(), 'year') ? 'MMM D' : 'MMM D, YYYY')
+}
+
+export function DetectableEffectTable({ experiment: experimentProp }: DetectableEffectTableProps): JSX.Element | null {
+ const logic = runningTimeLogic({ experiment: experimentProp })
+ const {
+ outlook,
+ currentExposures,
+ isComplete,
+ dailyExposureRate,
+ estimateMetric,
+ canIncludeMoreUsers,
+ experiment,
+ } = useValues(logic)
+ const { selectCheckpoint } = useActions(logic)
+
+ const startDate = experiment.start_date
+ if (!outlook || currentExposures === null || dailyExposureRate === null || !startDate) {
+ return null
+ }
+
+ const dateAt = (days: number): dayjs.Dayjs => dayjs(startDate).add(days * 24, 'hour')
+
+ return (
+
+
What the experiment can detect
+
checkpoint.key}
+ rowStatus={(checkpoint) => (checkpoint.isTarget ? 'highlighted' : null)}
+ rowClassName={(checkpoint) => (checkpoint.isTarget ? 'font-semibold' : null)}
+ columns={[
+ {
+ title: 'When',
+ key: 'when',
+ // Takes the spare width, so that the number columns stay in place while the target row moves.
+ width: '100%',
+ render: (_, checkpoint) => (
+
+ {checkpoint.isTarget && isComplete && (
+
+
+
+ )}
+ {checkpoint.isTarget && outlook.isOutOfReach && (
+
+
+
+ )}
+ {checkpoint.kind === 'now' ? 'Today' : formatDate(dateAt(checkpoint.days))}
+ {`· ${describeCheckpointDay(checkpoint, {
+ startDate,
+ currentSampleSize: currentExposures,
+ })}`}
+
+ ),
+ },
+ {
+ title: 'Exposures',
+ key: 'exposures',
+ align: 'right',
+ render: (_, checkpoint) => {checkpoint.exposures.toLocaleString()} ,
+ },
+ {
+ title: 'Detectable effect',
+ key: 'detectable',
+ align: 'right',
+ render: (_, checkpoint) => {formatDetectableEffect(checkpoint.detectableEffect)} ,
+ },
+ {
+ key: 'set-target',
+ align: 'right',
+ width: 0,
+ render: (_, checkpoint) => {
+ if (checkpoint.isTarget) {
+ return (
+
+ Target
+
+ )
+ }
+ const { effect, disabledReason, tooltip } = describeUseButton(checkpoint)
+ return (
+ selectCheckpoint(checkpoint.key, effect)}
+ data-attr="experiment-running-time-set-target"
+ >
+ Use
+
+ )
+ },
+ },
+ ]}
+ />
+
+ Based on
+ {estimateMetric?.name ?? 'the primary metric'}
+
+ {`${estimateMetric ? `, the ${estimateMetric.isFirstOfSeveral ? 'first ' : ''}primary metric` : ''}.${
+ estimateMetric?.hasOtherMetrics ? ' Other metrics can need more time.' : ''
+ } Projections assume about ${Math.round(dailyExposureRate).toLocaleString()} exposures a day, the average since launch.${
+ outlook.isOutOfReach && canIncludeMoreUsers
+ ? ' To finish sooner, include more users in the experiment.'
+ : ''
+ }`}
+
+
+
+ )
+}
diff --git a/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectField.tsx b/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectField.tsx
new file mode 100644
index 000000000000..f2a030229726
--- /dev/null
+++ b/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectField.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useState } from 'react'
+
+import { LemonInput } from '@posthog/lemon-ui'
+
+import { LemonSlider } from 'lib/lemon-ui/LemonSlider'
+import { Label } from 'lib/ui/Label/Label'
+import {
+ MINIMUM_DETECTABLE_EFFECT_MAX,
+ MINIMUM_DETECTABLE_EFFECT_MIN,
+} from 'scenes/experiments/RunningTimeCalculator/calculations'
+
+export interface MinimumDetectableEffectFieldProps {
+ value: number
+ onChange: (value: number) => void
+}
+
+const isInRange = (value: number | undefined): value is number =>
+ value !== undefined && value >= MINIMUM_DETECTABLE_EFFECT_MIN && value <= MINIMUM_DETECTABLE_EFFECT_MAX
+
+export function MinimumDetectableEffectField({ value, onChange }: MinimumDetectableEffectFieldProps): JSX.Element {
+ // The input keeps what the user types, so that an empty field or a partial number stays editable. Only a value in
+ // range reaches the estimate.
+ const [draft, setDraft] = useState(value)
+ useEffect(() => setDraft(value), [value])
+
+ const isDraftEmpty = draft === undefined || Number.isNaN(draft)
+
+ const commitDraft = (): void => {
+ if (draft === undefined || Number.isNaN(draft)) {
+ setDraft(value)
+ } else if (!isInRange(draft)) {
+ const clamped = Math.min(MINIMUM_DETECTABLE_EFFECT_MAX, Math.max(MINIMUM_DETECTABLE_EFFECT_MIN, draft))
+ setDraft(clamped)
+ onChange(clamped)
+ }
+ }
+
+ return (
+
+
Minimum detectable effect
+
+
+
+
+
+ {
+ setDraft(next)
+ if (isInRange(next)) {
+ onChange(next)
+ }
+ }}
+ onBlur={commitDraft}
+ onPressEnter={commitDraft}
+ status={isDraftEmpty || isInRange(draft) ? 'default' : 'danger'}
+ min={MINIMUM_DETECTABLE_EFFECT_MIN}
+ max={MINIMUM_DETECTABLE_EFFECT_MAX}
+ step={1}
+ className="flex-1"
+ data-attr="experiment-running-time-mde-input"
+ />
+ %
+
+
+ {!isDraftEmpty && !isInRange(draft) && (
+
+ {`Enter a value from ${MINIMUM_DETECTABLE_EFFECT_MIN} to ${MINIMUM_DETECTABLE_EFFECT_MAX}.`}
+
+ )}
+
+ )
+}
diff --git a/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectHelp.tsx b/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectHelp.tsx
new file mode 100644
index 000000000000..08a473155f71
--- /dev/null
+++ b/products/experiments/frontend/modals/RunningTimeConfigModal/MinimumDetectableEffectHelp.tsx
@@ -0,0 +1,14 @@
+export interface MinimumDetectableEffectHelpProps {
+ // The color too, so that the text matches the other hints where it sits.
+ className: string
+}
+
+export function MinimumDetectableEffectHelp({ className }: MinimumDetectableEffectHelpProps): JSX.Element {
+ return (
+
+ The minimum detectable effect is the smallest change you want to detect, relative to the control. Smaller
+ changes are harder to tell apart from random noise, so they need more exposures. It affects only the running
+ time, not how the metrics are calculated.
+
+ )
+}
diff --git a/products/experiments/frontend/modals/RunningTimeConfigModal/RunningTimeConfigModal.tsx b/products/experiments/frontend/modals/RunningTimeConfigModal/RunningTimeConfigModal.tsx
index 817ab2010a54..f6f3d2078765 100644
--- a/products/experiments/frontend/modals/RunningTimeConfigModal/RunningTimeConfigModal.tsx
+++ b/products/experiments/frontend/modals/RunningTimeConfigModal/RunningTimeConfigModal.tsx
@@ -1,9 +1,7 @@
import { useActions, useValues } from 'kea'
-import { IconCheck } from '@posthog/icons'
-import { LemonBanner, LemonButton, LemonInput, LemonModal, LemonSegmentedButton } from '@posthog/lemon-ui'
+import { LemonBanner, LemonButton, LemonInput, LemonModal, LemonSegmentedButton, Spinner } from '@posthog/lemon-ui'
-import { LemonSlider } from 'lib/lemon-ui/LemonSlider'
import { Label } from 'lib/ui/Label/Label'
import { ManualCalculatorMetricType } from 'scenes/experiments/RunningTimeCalculator/calculations'
import { runningTimeLogic } from 'scenes/experiments/RunningTimeCalculator/runningTimeLogic'
@@ -12,6 +10,10 @@ import { Experiment } from '~/types'
import { isLaunched } from 'products/experiments/frontend/experimentStatus'
+import { DetectableEffectTable } from './DetectableEffectTable'
+import { MinimumDetectableEffectField } from './MinimumDetectableEffectField'
+import { MinimumDetectableEffectHelp } from './MinimumDetectableEffectHelp'
+
export interface RunningTimeConfigModalProps {
experiment: Experiment
}
@@ -48,23 +50,25 @@ export function RunningTimeConfigModal({ experiment: experimentProp }: RunningTi
const {
config,
manualFormPreview,
- currentExposures,
- targetSampleSize,
- dailyExposureRate,
- remainingDays,
isRunningTimeConfigModalOpen,
experiment,
isSaving,
+ outlook,
+ isCalculating,
+ automaticTargetFailed,
+ automaticTargetRequest,
} = useValues(runningTimeLogic({ experiment: experimentProp }))
- const { setConfig, save, cancel } = useActions(runningTimeLogic({ experiment: experimentProp }))
+ const { setConfig, save, cancel, loadAutomaticTarget } = useActions(
+ runningTimeLogic({ experiment: experimentProp })
+ )
- const hasAutomaticData = remainingDays !== null
const isPreLaunch = !isLaunched(experiment)
+ const effectField = setConfig({ mde })} />
return (
-
+
- {isPreLaunch ? 'Estimate running time' : 'Running time configuration'}
+ {isPreLaunch ? 'Estimate running time' : 'Running time'}
@@ -85,11 +89,11 @@ export function RunningTimeConfigModal({ experiment: experimentProp }: RunningTi
value={config.mode}
onChange={(value) => setConfig({ mode: value as 'automatic' | 'manual' })}
/>
-
- {config.mode === 'manual'
- ? 'Enter your own values to calculate sample size and running time.'
- : 'Uses actual experiment data to calculate estimates.'}
-
+ {config.mode === 'manual' && (
+
+ Enter your own values to calculate sample size and running time.
+
+ )}
{config.mode === 'manual' ? (
@@ -144,35 +148,8 @@ export function RunningTimeConfigModal({ experiment: experimentProp }: RunningTi
-
Minimum detectable effect
-
-
- setConfig({ mde: Math.round(value * 10) / 10 })}
- min={1}
- max={100}
- step={0.1}
- />
-
-
- setConfig({ mde: Number(value) || 1 })}
- min={1}
- max={100}
- step={0.1}
- className="flex-1"
- />
- %
-
-
-
- The smallest delta (change) you want to be able to measure with statistical
- significance. Changing this setting does not impact the statistical analysis, only
- the estimated runtime. Lower values require more data and longer run times.
-
+ {effectField}
+
@@ -192,74 +169,36 @@ export function RunningTimeConfigModal({ experiment: experimentProp }: RunningTi
>
) : (
<>
- {!hasAutomaticData ? (
-
- Waiting for sufficient data to calculate time estimates. Need at least 1 day and 100
- exposures.
-
- ) : (
- <>
-
-
Remaining time
-
- {remainingDays === 0 ? (
-
-
- Complete
-
- ) : (
- `~${Math.ceil(remainingDays)} day${Math.ceil(remainingDays) !== 1 ? 's' : ''}`
- )}
-
+ {effectField}
+
+ {!outlook && isCalculating ? (
+
+
+ Calculating the running time…
-
-
Progress
-
- {currentExposures !== null && targetSampleSize !== null
- ? `${currentExposures.toLocaleString()} / ${targetSampleSize.toLocaleString()} exposures`
- : '—'}
-
-
-
-
Rate
-
- {dailyExposureRate !== null
- ? `~${Math.round(dailyExposureRate).toLocaleString()} exposures/day`
- : '—'}
-
-
- >
- )}
-
-
Minimum detectable effect
-
-
- setConfig({ mde: Math.round(value * 10) / 10 })}
- min={1}
- max={100}
- step={0.1}
- />
-
-
- setConfig({ mde: Number(value) || 1 })}
- min={1}
- max={100}
- step={0.1}
- className="flex-1"
- />
- %
-
-
-
- The smallest delta (change) you want to be able to measure with statistical
- significance. Changing this setting does not impact the statistical analysis, only
- the estimated runtime. Lower values require more data and longer run times.
-
+ ) : automaticTargetFailed ? (
+
loadAutomaticTarget(automaticTargetRequest),
+ }
+ : undefined
+ }
+ >
+ Couldn't calculate the running time.
+
+ ) : !outlook ? (
+
+ Not enough data to estimate the running time yet. It needs exposures, and some
+ activity on the primary metric, in each variant.
+
+ ) : (
+
+ )}
+
>
)}
diff --git a/products/experiments/skills/diagnosing-experiment-health/references/interpretation.md b/products/experiments/skills/diagnosing-experiment-health/references/interpretation.md
index a8674f3f5212..c20f03253a89 100644
--- a/products/experiments/skills/diagnosing-experiment-health/references/interpretation.md
+++ b/products/experiments/skills/diagnosing-experiment-health/references/interpretation.md
@@ -339,6 +339,9 @@ real anomaly worth investigating with the experiment URL.
**Behavior** `[HIGH]`. The duration display next to the experiment's status is an estimate of sample size.
It reads "~N days left" while the exposures are below the target, and "Target reached" once they are at or above it.
+When the target is more than four weeks away, it reads "Can detect X%+ changes within 4 weeks" with a warning icon instead.
+X is the smallest change that the experiment can detect within four weeks at its current traffic, not the minimum detectable effect.
+Above 100% it reads "Can detect only changes over 100% within 4 weeks".
Older versions read "Complete".
On an ended experiment "Complete" is the status tag: every ended experiment carries it, and the duration display is hidden.
Read `status` first when the user says "complete".
@@ -352,6 +355,8 @@ Read `status` first when the user says "complete".
- In manual mode the target comes from the baseline the user typed, not from the results.
- "Target reached" says that an effect of that size would probably have shown.
It says nothing about significance, and it does not end the experiment.
+- The running time settings list the exposures and the change that the experiment can detect today, at the next week marks, and on the date it reaches the target.
+ A change over 100% shows as ">100%".