From ddddac112ff36361abd96d3719fc5379a04ec11b Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 8 Oct 2026 13:59:12 -0700 Subject: [PATCH 1/7] fix(flags): open flag calls from event links that name no event On flag_evaluations_mode 2 a $feature_flag_called event lives only in flag_evaluations. An event link without the event name, such as the one on the destination Invocations tab, opens the events scene on the events table and shows nothing. When the URL opens an event lookup that names no event on mode 2, the events scene runs the lookup again with the flag call event. If that finds the row, the scene names the event, which reads flag_evaluations. An edit in the scene does not trigger the check, so clearing the event stays cleared. Claude-Session: https://claude.ai/code/session_01EvZDsoSPrQzLQit6kYQydV --- .../src/scenes/activity/explore/defaults.ts | 20 +++- .../activity/explore/eventsSceneLogic.test.ts | 107 +++++++++++++++++- .../activity/explore/eventsSceneLogic.tsx | 51 ++++++++- frontend/src/scenes/scenes.ts | 12 +- 4 files changed, 175 insertions(+), 15 deletions(-) diff --git a/frontend/src/scenes/activity/explore/defaults.ts b/frontend/src/scenes/activity/explore/defaults.ts index efad5221bebc..7e73eb1d3bd7 100644 --- a/frontend/src/scenes/activity/explore/defaults.ts +++ b/frontend/src/scenes/activity/explore/defaults.ts @@ -1,6 +1,8 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' -import { DataTableNode, NodeKind } from '~/queries/schema/schema-general' -import { AnyPropertyFilter, TeamPublicType, TeamType } from '~/types' +import { DataTableNode, EventsQuery, NodeKind } from '~/queries/schema/schema-general' +import { AnyPropertyFilter, PropertyFilterType, TeamPublicType, TeamType } from '~/types' + +const EVENT_LOOKUP_FILTER = /^uuid = '([a-f0-9-]+)'$/ export const getDefaultEventsSceneQuery = (properties?: AnyPropertyFilter[]): DataTableNode => ({ kind: NodeKind.DataTableNode, @@ -17,6 +19,20 @@ export const getDefaultEventsSceneQuery = (properties?: AnyPropertyFilter[]): Da showPersistentColumnConfigurator: true, }) +// An event link opens the events scene with this query. getEventLookupUuid recognizes its filter, so change the two together. +export const getEventLookupQuery = (id: string): DataTableNode => + getDefaultEventsSceneQuery([ + { type: PropertyFilterType.HogQL, key: `uuid = '${id.replaceAll(/[^a-f0-9-]/g, '')}'`, value: null }, + ]) + +export function getEventLookupUuid(source: EventsQuery): string | null { + const [filter, ...otherFilters] = source.properties ?? [] + if (otherFilters.length > 0 || filter?.type !== PropertyFilterType.HogQL) { + return null + } + return EVENT_LOOKUP_FILTER.exec(filter.key)?.[1] ?? null +} + export function applyTestAccountFilter( base: T, currentTeam: TeamType | TeamPublicType | null | undefined, diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts index c50e09f9e73d..7fb7e02ff2b2 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts @@ -10,10 +10,13 @@ import { FlagEvaluationsModeEnumApi } from '~/generated/core/api.schemas' import { useMocks } from '~/mocks/jest' import { DataTableNode, EventsQuery, NodeKind } from '~/queries/schema/schema-general' import { initKeaTests } from '~/test/init' -import { ActivityTab } from '~/types' +import { ActivityTab, PropertyFilterType, PropertyOperator } from '~/types' +import { getEventLookupQuery } from './defaults' import { FlagCallsNote, eventsSceneLogic } from './eventsSceneLogic' +const LOOKUP_UUID = '018f0000-0000-7000-8000-000000000002' + describe('eventsSceneLogic', () => { let logic: ReturnType @@ -121,4 +124,106 @@ describe('eventsSceneLogic', () => { expect(logic.values.flagCallsNote).toBe(expected) }) + + test.each< + [ + string, + FlagEvaluationsModeEnumApi, + Partial, + 'row' | 'no row' | 'error', + 'link' | 'edit', + string | undefined, + ] + >([ + [ + 'a link to a flag call on mode 2', + FlagEvaluationsModeEnumApi.Number2, + {}, + 'row', + 'link', + '$feature_flag_called', + ], + [ + 'a link on mode 2 that flag_evaluations does not hold', + FlagEvaluationsModeEnumApi.Number2, + {}, + 'no row', + 'link', + undefined, + ], + [ + 'a link on mode 2 whose flag call query fails', + FlagEvaluationsModeEnumApi.Number2, + {}, + 'error', + 'link', + undefined, + ], + ['a link on mode 1', FlagEvaluationsModeEnumApi.Number1, {}, 'row', 'link', undefined], + [ + 'a link that names its event', + FlagEvaluationsModeEnumApi.Number2, + { event: '$pageview' }, + 'row', + 'link', + '$pageview', + ], + [ + 'a link with another filter', + FlagEvaluationsModeEnumApi.Number2, + { + properties: [ + ...(getEventLookupQuery(LOOKUP_UUID).source as EventsQuery).properties!, + { + type: PropertyFilterType.Event, + key: '$browser', + operator: PropertyOperator.Exact, + value: 'Chrome', + }, + ], + }, + 'row', + 'link', + undefined, + ], + ['an edit that clears the event of a lookup', FlagEvaluationsModeEnumApi.Number2, {}, 'row', 'edit', undefined], + ])( + 'resolves the event name for %s', + async (_name, mode, sourceOverrides, flagCallQuery, arrival, expectedEvent) => { + const after = '2026-08-24T00:00:00.000Z' + const before = '2026-08-24T00:00:30.000Z' + useMocks({ + post: { + '/api/environments/:team_id/query/:kind': async ({ request }) => { + const { query } = (await request.json()) as Record + const asksForTheFlagCall = + query.kind === NodeKind.EventsQuery && + query.event === '$feature_flag_called' && + JSON.stringify(query.properties).includes(LOOKUP_UUID) && + query.after === after && + query.before === before + if (asksForTheFlagCall && flagCallQuery === 'error') { + return [500, { detail: 'Query failed' }] + } + return [200, { results: asksForTheFlagCall && flagCallQuery === 'row' ? [[LOOKUP_UUID]] : [] }] + }, + }, + }) + teamLogic.actions.loadCurrentTeamSuccess({ ...MOCK_DEFAULT_TEAM, flag_evaluations_mode: mode }) + const lookup = getEventLookupQuery(LOOKUP_UUID) + const query: DataTableNode = { + ...lookup, + source: { ...(lookup.source as EventsQuery), after, before, ...sourceOverrides }, + } + + if (arrival === 'link') { + router.actions.push(combineUrl(urls.activity(ActivityTab.ExploreEvents), {}, { q: query }).url) + } else { + logic.actions.setQuery(query) + } + await expectLogic(logic).toFinishAllListeners() + + expect(((logic.values.query as DataTableNode).source as EventsQuery).event).toBe(expectedEvent) + } + ) }) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index d7daa9a6ebb5..1bdabc2385c7 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -8,7 +8,11 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { trackedActionToUrl } from 'lib/logic/scenes/trackedActionToUrl' import { tabUiStateLogic } from 'lib/logic/tabUiStateLogic' import { objectsEqual } from 'lib/utils/objects' -import { applyTestAccountFilter, getDefaultEventsSceneQuery } from 'scenes/activity/explore/defaults' +import { + applyTestAccountFilter, + getDefaultEventsSceneQuery, + getEventLookupUuid, +} from 'scenes/activity/explore/defaults' import { reachesPastFlagEvaluationsRetention, readsFlagEvaluationsTable, @@ -21,7 +25,8 @@ import { urls } from 'scenes/urls' import { FlagEvaluationsModeEnumApi } from '~/generated/core/api.schemas' import { getDefaultEventsQueryForTeam } from '~/queries/nodes/DataTable/defaultEventsQuery' -import { DataTableNode, EventsQuery, Node } from '~/queries/schema/schema-general' +import { performQuery } from '~/queries/query' +import { DataTableNode, EventsQuery, Node, ProductKey } from '~/queries/schema/schema-general' import { isDataTableNode, isEventsQuery } from '~/queries/utils' import { ActivityTab, Breadcrumb } from '~/types' @@ -42,6 +47,10 @@ function listsOnlyFlagCalls({ source, actionId, actionSteps, event, events }: Ev return eventNames.length > 0 && eventNames.every((name) => name === FEATURE_FLAG_CALLED_EVENT) } +function isUnnamedEventLookup(source: EventsQuery): boolean { + return !source.event && !source.events?.length && getEventLookupUuid(source) !== null +} + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface eventsSceneLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic @@ -66,6 +75,9 @@ export interface eventsSceneLogicActions { sceneKey: string tabId: string } // tabUiStateLogic + nameFlagCallLookup: (query: Node) => { + query: Node> + } setQuery: (query: Node) => { query: Node> } @@ -114,13 +126,44 @@ export const eventsSceneLogic = kea([ actions: [tabUiStateLogic, ['setSavedQueryForTab']], })), - actions({ setQuery: (query: Node) => ({ query }) }), + actions({ setQuery: (query: Node) => ({ query }), nameFlagCallLookup: (query: Node) => ({ query }) }), reducers({ savedQuery: [null as Node | null, { setQuery: (_, { query }) => query }] }), listeners(({ actions, values }) => ({ setQuery: ({ query }) => { const isDefault = objectsEqual(query, values.defaultQuery) actions.setSavedQueryForTab(undefined, 'events', isDefault ? null : query) }, + // A lookup that names no event reads the events table, which holds no flag call on flag_evaluations_mode 2. + // This listener runs the lookup again with the flag call event, which reads flag_evaluations, and names the + // event when that finds the row. Only the URL handler dispatches it, so a user who clears the event keeps it cleared. + nameFlagCallLookup: async ({ query }, breakpoint) => { + if ( + values.currentTeam?.flag_evaluations_mode !== FLAG_EVALUATIONS_ONLY_MODE || + !isDataTableNode(query) || + !isEventsQuery(query.source) || + !isUnnamedEventLookup(query.source) + ) { + return + } + const namedSource: EventsQuery = { ...query.source, event: FEATURE_FLAG_CALLED_EVENT } + const namedQuery: DataTableNode = { ...query, source: namedSource } + let response + try { + response = await performQuery({ + ...namedSource, + select: ['uuid'], + limit: 1, + tags: { productKey: ProductKey.FEATURE_FLAGS }, + }) + } catch { + // performQuery reports the failure, and the lookup keeps reading the events table. + return + } + breakpoint() + if (response.results.length > 0 && objectsEqual(values.query, query)) { + actions.setQuery(namedQuery) + } + }, })), selectors({ defaultQuery: [ @@ -194,10 +237,12 @@ export const eventsSceneLogic = kea([ const target = persisted ?? values.defaultQuery if (!objectsEqual(values.query, target)) { actions.setQuery(target) + actions.nameFlagCallLookup(target) } } else { if (typeof queryParam === 'object') { actions.setQuery(queryParam) + actions.nameFlagCallLookup(queryParam) } else { lemonToast.error('Invalid query in URL') console.error({ queryParam }) diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 53e086d097b0..7eddce32d59e 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -3,7 +3,7 @@ import { combineUrl } from 'kea-router' import { dayjs } from 'lib/dayjs' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { tryDecodeURIComponent } from 'lib/utils/url' -import { getDefaultEventsSceneQuery } from 'scenes/activity/explore/defaults' +import { getEventLookupQuery } from 'scenes/activity/explore/defaults' import { Params, Scene, SceneConfig, SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -14,7 +14,7 @@ import { ErrorProjectAccessDenied as ErrorProjectAccessDeniedComponent } from '~ import { ErrorProjectUnavailable as ErrorProjectUnavailableComponent } from '~/layout/ErrorProjectUnavailable' import { productConfiguration, productRedirects, productRoutes } from '~/products' import { EventsQuery } from '~/queries/schema/schema-general' -import { ActivityScope, ActivityTab, InsightShortId, PropertyFilterType, ReplayTabs } from '~/types' +import { ActivityScope, ActivityTab, InsightShortId, ReplayTabs } from '~/types' import { BillingSectionId } from './billing/types' @@ -652,13 +652,7 @@ export const redirects: Record< '/events': urls.activity(), '/events/:id/*': ({ id, _ }, { event }) => { - const query = getDefaultEventsSceneQuery([ - { - type: PropertyFilterType.HogQL, - key: `uuid = '${id.replaceAll(/[^a-f0-9-]/g, '')}'`, - value: null, - }, - ]) + const query = getEventLookupQuery(id) const source = query.source as EventsQuery if (typeof event === 'string' && event) { // The events query reads some events, such as flag calls, from their own table. From 75d26f2903fb5c4ecd9d5ed62e5bd3bc32fd31ec Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 8 Oct 2026 15:07:43 -0700 Subject: [PATCH 2/7] Continue work on event-link-flag-evaluations-fallback Claude-Session: https://claude.ai/code/session_01YRizWgRS9zdbh43fr76H8N --- .../src/scenes/activity/explore/defaults.ts | 17 +++++---- .../activity/explore/eventsSceneLogic.tsx | 35 +++++++------------ 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/frontend/src/scenes/activity/explore/defaults.ts b/frontend/src/scenes/activity/explore/defaults.ts index 7e73eb1d3bd7..cc1323df78d4 100644 --- a/frontend/src/scenes/activity/explore/defaults.ts +++ b/frontend/src/scenes/activity/explore/defaults.ts @@ -2,7 +2,7 @@ import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils' import { DataTableNode, EventsQuery, NodeKind } from '~/queries/schema/schema-general' import { AnyPropertyFilter, PropertyFilterType, TeamPublicType, TeamType } from '~/types' -const EVENT_LOOKUP_FILTER = /^uuid = '([a-f0-9-]+)'$/ +const EVENT_LOOKUP_FILTER = /^uuid = '[a-f0-9-]+'$/ export const getDefaultEventsSceneQuery = (properties?: AnyPropertyFilter[]): DataTableNode => ({ kind: NodeKind.DataTableNode, @@ -19,18 +19,21 @@ export const getDefaultEventsSceneQuery = (properties?: AnyPropertyFilter[]): Da showPersistentColumnConfigurator: true, }) -// An event link opens the events scene with this query. getEventLookupUuid recognizes its filter, so change the two together. +// An event link opens the events scene with this query. isUnnamedEventLookup recognizes its filter. Change the two together. export const getEventLookupQuery = (id: string): DataTableNode => getDefaultEventsSceneQuery([ { type: PropertyFilterType.HogQL, key: `uuid = '${id.replaceAll(/[^a-f0-9-]/g, '')}'`, value: null }, ]) -export function getEventLookupUuid(source: EventsQuery): string | null { +export function isUnnamedEventLookup(source: EventsQuery): boolean { const [filter, ...otherFilters] = source.properties ?? [] - if (otherFilters.length > 0 || filter?.type !== PropertyFilterType.HogQL) { - return null - } - return EVENT_LOOKUP_FILTER.exec(filter.key)?.[1] ?? null + return ( + !source.event && + !source.events?.length && + otherFilters.length === 0 && + filter?.type === PropertyFilterType.HogQL && + EVENT_LOOKUP_FILTER.test(filter.key) + ) } export function applyTestAccountFilter( diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index 1bdabc2385c7..4f0989102d7f 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -11,7 +11,7 @@ import { objectsEqual } from 'lib/utils/objects' import { applyTestAccountFilter, getDefaultEventsSceneQuery, - getEventLookupUuid, + isUnnamedEventLookup, } from 'scenes/activity/explore/defaults' import { reachesPastFlagEvaluationsRetention, @@ -47,10 +47,6 @@ function listsOnlyFlagCalls({ source, actionId, actionSteps, event, events }: Ev return eventNames.length > 0 && eventNames.every((name) => name === FEATURE_FLAG_CALLED_EVENT) } -function isUnnamedEventLookup(source: EventsQuery): boolean { - return !source.event && !source.events?.length && getEventLookupUuid(source) !== null -} - // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface eventsSceneLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic @@ -133,9 +129,10 @@ export const eventsSceneLogic = kea([ const isDefault = objectsEqual(query, values.defaultQuery) actions.setSavedQueryForTab(undefined, 'events', isDefault ? null : query) }, - // A lookup that names no event reads the events table, which holds no flag call on flag_evaluations_mode 2. - // This listener runs the lookup again with the flag call event, which reads flag_evaluations, and names the - // event when that finds the row. Only the URL handler dispatches it, so a user who clears the event keeps it cleared. + // A lookup that names no event reads the events table. On flag_evaluations_mode 2 that table holds no flag call. + // This listener runs the lookup again with the flag call event, which reads flag_evaluations. + // It names the event when that query finds the row. + // Only a query in the URL dispatches this listener. A user who clears the event therefore keeps it cleared. nameFlagCallLookup: async ({ query }, breakpoint) => { if ( values.currentTeam?.flag_evaluations_mode !== FLAG_EVALUATIONS_ONLY_MODE || @@ -147,20 +144,15 @@ export const eventsSceneLogic = kea([ } const namedSource: EventsQuery = { ...query.source, event: FEATURE_FLAG_CALLED_EVENT } const namedQuery: DataTableNode = { ...query, source: namedSource } - let response - try { - response = await performQuery({ - ...namedSource, - select: ['uuid'], - limit: 1, - tags: { productKey: ProductKey.FEATURE_FLAGS }, - }) - } catch { - // performQuery reports the failure, and the lookup keeps reading the events table. - return - } + // performQuery reports a failure. On a failure the lookup keeps reading the events table. + const response = await performQuery({ + ...namedSource, + select: ['uuid'], + limit: 1, + tags: { productKey: ProductKey.FEATURE_FLAGS }, + }).catch(() => null) breakpoint() - if (response.results.length > 0 && objectsEqual(values.query, query)) { + if (response?.results.length && objectsEqual(values.query, query)) { actions.setQuery(namedQuery) } }, @@ -237,7 +229,6 @@ export const eventsSceneLogic = kea([ const target = persisted ?? values.defaultQuery if (!objectsEqual(values.query, target)) { actions.setQuery(target) - actions.nameFlagCallLookup(target) } } else { if (typeof queryParam === 'object') { From 395d355fd17b74e9e3232572823455cfd849c67d Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:33:19 +0000 Subject: [PATCH 3/7] fix(flags): keep a cleared event after a date edit on the events page The events scene compared the URL query with the in-memory query before it treated the URL as a new link. The URL stores the query as JSON, which drops keys set to undefined, and the date range writes before: undefined. So the scene's own URL update looked like a new link and ran the flag call check again. On flag evaluations mode 2 that check put $feature_flag_called back after the user cleared it. The handler now compares against the query in its JSON form. Generated-By: PostHog Desktop Task-Id: 5a7416ba-1b4e-478e-93c0-577724bb689b --- .../activity/explore/eventsSceneLogic.test.ts | 38 +++++++++++++++++++ .../activity/explore/eventsSceneLogic.tsx | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts index 7fb7e02ff2b2..c891cc23d7ac 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts @@ -226,4 +226,42 @@ describe('eventsSceneLogic', () => { expect(((logic.values.query as DataTableNode).source as EventsQuery).event).toBe(expectedEvent) } ) + + it('keeps a cleared event when a later edit writes an undefined key', async () => { + let flagCallQueries = 0 + useMocks({ + post: { + '/api/environments/:team_id/query/:kind': async ({ request }) => { + const { query } = (await request.json()) as Record + if (query.event !== '$feature_flag_called') { + return [200, { results: [] }] + } + flagCallQueries++ + return [200, { results: [[LOOKUP_UUID]] }] + }, + }, + }) + teamLogic.actions.loadCurrentTeamSuccess({ + ...MOCK_DEFAULT_TEAM, + flag_evaluations_mode: FlagEvaluationsModeEnumApi.Number2, + }) + router.actions.push( + combineUrl(urls.activity(ActivityTab.ExploreEvents), {}, { q: getEventLookupQuery(LOOKUP_UUID) }).url + ) + await expectLogic(logic).toFinishAllListeners() + const named = logic.values.query as DataTableNode + const cleared: DataTableNode = { ...named, source: { ...(named.source as EventsQuery), event: '' } } + const redated: DataTableNode = { + ...cleared, + source: { ...(cleared.source as EventsQuery), after: '-24h', before: undefined }, + } + + logic.actions.setQuery(cleared) + await expectLogic(logic).toFinishAllListeners() + logic.actions.setQuery(redated) + await expectLogic(logic).toFinishAllListeners() + + expect(((logic.values.query as DataTableNode).source as EventsQuery).event).toBe('') + expect(flagCallQueries).toBe(1) + }) }) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index 4f0989102d7f..5aa2c5dccf32 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -221,7 +221,9 @@ export const eventsSceneLogic = kea([ urlToAction(({ actions, values }) => { const eventsQueryHandler: UrlToActionPayload[keyof UrlToActionPayload] = (_, __, { q: queryParam }): void => { - if (!equal(queryParam, values.query)) { + // The URL holds the query as JSON, which drops keys set to undefined. + // Compare the query in that form, so that the scene's own URL update does not count as a new link. + if (!equal(queryParam, JSON.parse(JSON.stringify(values.query)))) { // nothing in the URL if (!queryParam) { // restore from the persisted query if present, else fall back to default From 477569fe97ff66eb720400e1e3d76733f6b9e1b8 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 8 Oct 2026 15:51:19 -0700 Subject: [PATCH 4/7] Address review findings Claude-Session: https://claude.ai/code/session_01YRizWgRS9zdbh43fr76H8N --- .../activity/explore/eventsSceneLogic.test.ts | 31 +++++++++++++++++-- .../activity/explore/eventsSceneLogic.tsx | 3 +- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts index c891cc23d7ac..2d7e2db9b502 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts @@ -3,6 +3,7 @@ import { MOCK_DEFAULT_TEAM } from 'lib/api.mock' import { combineUrl, router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { tabUiStateLogic } from 'lib/logic/tabUiStateLogic' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -131,7 +132,7 @@ describe('eventsSceneLogic', () => { FlagEvaluationsModeEnumApi, Partial, 'row' | 'no row' | 'error', - 'link' | 'edit', + 'link' | 'link, then edit' | 'edit' | 'restore', string | undefined, ] >([ @@ -186,7 +187,23 @@ describe('eventsSceneLogic', () => { 'link', undefined, ], + [ + 'a link whose query the user edits before the check answers', + FlagEvaluationsModeEnumApi.Number2, + {}, + 'row', + 'link, then edit', + undefined, + ], ['an edit that clears the event of a lookup', FlagEvaluationsModeEnumApi.Number2, {}, 'row', 'edit', undefined], + [ + 'a restored tab whose lookup names no event', + FlagEvaluationsModeEnumApi.Number2, + {}, + 'row', + 'restore', + undefined, + ], ])( 'resolves the event name for %s', async (_name, mode, sourceOverrides, flagCallQuery, arrival, expectedEvent) => { @@ -216,11 +233,19 @@ describe('eventsSceneLogic', () => { source: { ...(lookup.source as EventsQuery), after, before, ...sourceOverrides }, } - if (arrival === 'link') { + if (arrival === 'link' || arrival === 'link, then edit') { router.actions.push(combineUrl(urls.activity(ActivityTab.ExploreEvents), {}, { q: query }).url) - } else { + } + if (arrival === 'link, then edit') { + logic.actions.setQuery({ ...query, source: { ...(query.source as EventsQuery), after: '-7d' } }) + } + if (arrival === 'edit') { logic.actions.setQuery(query) } + if (arrival === 'restore') { + tabUiStateLogic.actions.setSavedQueryForTab(undefined, 'events', query) + router.actions.push(urls.activity(ActivityTab.ExploreEvents)) + } await expectLogic(logic).toFinishAllListeners() expect(((logic.values.query as DataTableNode).source as EventsQuery).event).toBe(expectedEvent) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index 5aa2c5dccf32..10e87289215e 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -132,7 +132,8 @@ export const eventsSceneLogic = kea([ // A lookup that names no event reads the events table. On flag_evaluations_mode 2 that table holds no flag call. // This listener runs the lookup again with the flag call event, which reads flag_evaluations. // It names the event when that query finds the row. - // Only a query in the URL dispatches this listener. A user who clears the event therefore keeps it cleared. + // Only a query in the URL dispatches this listener. An edit in the open scene does not dispatch it. + // A reload after the user clears the event runs the check again, because the URL then holds the cleared lookup. nameFlagCallLookup: async ({ query }, breakpoint) => { if ( values.currentTeam?.flag_evaluations_mode !== FLAG_EVALUATIONS_ONLY_MODE || From a39400b8036eade150b4b605611c042fe482a5cf Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:05:48 +0000 Subject: [PATCH 5/7] perf(flags): skip presorted read in the flag call check The flag call check inherited orderBy ['timestamp DESC'] from the lookup query. That sends EventsQueryRunner down the presorted path, which reads flag_evaluations in an inner uuid query and again in the outer query. The check selects only uuid with a limit of one, so the second read gains nothing. An empty orderBy skips the presorted path. The named query that the table runs keeps its order. Generated-By: PostHog Desktop Task-Id: a9ae5584-1281-47c8-b453-5116f380044c --- frontend/src/scenes/activity/explore/eventsSceneLogic.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index 10e87289215e..961d0a26bcbf 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -149,6 +149,8 @@ export const eventsSceneLogic = kea([ const response = await performQuery({ ...namedSource, select: ['uuid'], + // An empty order skips the runner's presorted path, which reads flag_evaluations two times. + orderBy: [], limit: 1, tags: { productKey: ProductKey.FEATURE_FLAGS }, }).catch(() => null) From 6b51851346fe88d48124f167844eaa91826e45cc Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 8 Oct 2026 16:11:35 -0700 Subject: [PATCH 6/7] Simplify review fixes Claude-Session: https://claude.ai/code/session_01YRizWgRS9zdbh43fr76H8N --- .../activity/explore/eventsSceneLogic.test.ts | 28 +++++++++++-------- .../activity/explore/eventsSceneLogic.tsx | 11 +++++--- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts index 2d7e2db9b502..3b619a23a5ac 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts @@ -233,18 +233,22 @@ describe('eventsSceneLogic', () => { source: { ...(lookup.source as EventsQuery), after, before, ...sourceOverrides }, } - if (arrival === 'link' || arrival === 'link, then edit') { - router.actions.push(combineUrl(urls.activity(ActivityTab.ExploreEvents), {}, { q: query }).url) - } - if (arrival === 'link, then edit') { - logic.actions.setQuery({ ...query, source: { ...(query.source as EventsQuery), after: '-7d' } }) - } - if (arrival === 'edit') { - logic.actions.setQuery(query) - } - if (arrival === 'restore') { - tabUiStateLogic.actions.setSavedQueryForTab(undefined, 'events', query) - router.actions.push(urls.activity(ActivityTab.ExploreEvents)) + const linkUrl = combineUrl(urls.activity(ActivityTab.ExploreEvents), {}, { q: query }).url + switch (arrival) { + case 'link': + router.actions.push(linkUrl) + break + case 'link, then edit': + router.actions.push(linkUrl) + logic.actions.setQuery({ ...query, source: { ...(query.source as EventsQuery), after: '-7d' } }) + break + case 'edit': + logic.actions.setQuery(query) + break + case 'restore': + tabUiStateLogic.actions.setSavedQueryForTab(undefined, 'events', query) + router.actions.push(urls.activity(ActivityTab.ExploreEvents)) + break } await expectLogic(logic).toFinishAllListeners() diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx index 961d0a26bcbf..eea311381710 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.tsx @@ -122,7 +122,12 @@ export const eventsSceneLogic = kea([ actions: [tabUiStateLogic, ['setSavedQueryForTab']], })), - actions({ setQuery: (query: Node) => ({ query }), nameFlagCallLookup: (query: Node) => ({ query }) }), + actions({ + // The URL holds the query as JSON, which drops keys set to undefined. A date edit writes before: undefined. + // The scene stores the query in its JSON form, so that the scene's own URL update matches the stored query. + setQuery: (query: Node) => ({ query: JSON.parse(JSON.stringify(query)) as Node }), + nameFlagCallLookup: (query: Node) => ({ query }), + }), reducers({ savedQuery: [null as Node | null, { setQuery: (_, { query }) => query }] }), listeners(({ actions, values }) => ({ setQuery: ({ query }) => { @@ -224,9 +229,7 @@ export const eventsSceneLogic = kea([ urlToAction(({ actions, values }) => { const eventsQueryHandler: UrlToActionPayload[keyof UrlToActionPayload] = (_, __, { q: queryParam }): void => { - // The URL holds the query as JSON, which drops keys set to undefined. - // Compare the query in that form, so that the scene's own URL update does not count as a new link. - if (!equal(queryParam, JSON.parse(JSON.stringify(values.query)))) { + if (!equal(queryParam, values.query)) { // nothing in the URL if (!queryParam) { // restore from the persisted query if present, else fall back to default From fc7e97ef496b5484c2eadc4b2d2b8578f391adae Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 8 Oct 2026 16:40:17 -0700 Subject: [PATCH 7/7] Type the edited query in the events scene test Claude-Session: https://claude.ai/code/session_01YRizWgRS9zdbh43fr76H8N --- .../src/scenes/activity/explore/eventsSceneLogic.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts index 3b619a23a5ac..de5d5d8f9fac 100644 --- a/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts +++ b/frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts @@ -238,10 +238,15 @@ describe('eventsSceneLogic', () => { case 'link': router.actions.push(linkUrl) break - case 'link, then edit': + case 'link, then edit': { router.actions.push(linkUrl) - logic.actions.setQuery({ ...query, source: { ...(query.source as EventsQuery), after: '-7d' } }) + const edited: DataTableNode = { + ...query, + source: { ...(query.source as EventsQuery), after: '-7d' }, + } + logic.actions.setQuery(edited) break + } case 'edit': logic.actions.setQuery(query) break