Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions frontend/src/scenes/activity/explore/defaults.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +19,23 @@ export const getDefaultEventsSceneQuery = (properties?: AnyPropertyFilter[]): Da
showPersistentColumnConfigurator: true,
})

// 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 isUnnamedEventLookup(source: EventsQuery): boolean {
const [filter, ...otherFilters] = source.properties ?? []
return (
!source.event &&
!source.events?.length &&
otherFilters.length === 0 &&
filter?.type === PropertyFilterType.HogQL &&
EVENT_LOOKUP_FILTER.test(filter.key)
)
}

export function applyTestAccountFilter<T extends DataTableNode>(
base: T,
currentTeam: TeamType | TeamPublicType | null | undefined,
Expand Down
179 changes: 178 additions & 1 deletion frontend/src/scenes/activity/explore/eventsSceneLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ 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'

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<typeof eventsSceneLogic.build>

Expand Down Expand Up @@ -121,4 +125,177 @@ describe('eventsSceneLogic', () => {

expect(logic.values.flagCallsNote).toBe(expected)
})

test.each<
[
string,
FlagEvaluationsModeEnumApi,
Partial<EventsQuery>,
'row' | 'no row' | 'error',
'link' | 'link, then edit' | 'edit' | 'restore',
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,
],
[
'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) => {
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<string, any>
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 },
}

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)
const edited: DataTableNode = {
...query,
source: { ...(query.source as EventsQuery), after: '-7d' },
}
logic.actions.setQuery(edited)
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()

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<string, any>
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)
})
})
50 changes: 47 additions & 3 deletions frontend/src/scenes/activity/explore/eventsSceneLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
isUnnamedEventLookup,
} from 'scenes/activity/explore/defaults'
import {
reachesPastFlagEvaluationsRetention,
readsFlagEvaluationsTable,
Expand All @@ -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'

Expand Down Expand Up @@ -66,6 +71,9 @@ export interface eventsSceneLogicActions {
sceneKey: string
tabId: string
} // tabUiStateLogic
nameFlagCallLookup: (query: Node) => {
query: Node<Record<string, any>>
}
setQuery: (query: Node) => {
query: Node<Record<string, any>>
}
Expand Down Expand Up @@ -114,13 +122,48 @@ export const eventsSceneLogic = kea<eventsSceneLogicType>([
actions: [tabUiStateLogic, ['setSavedQueryForTab']],
})),

actions({ setQuery: (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 }) => {
const isDefault = objectsEqual(query, values.defaultQuery)
actions.setSavedQueryForTab(undefined, 'events', isDefault ? null : query)
},
// 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. 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 ||
!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 }
// performQuery reports a failure. On a failure the lookup keeps reading the events table.
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 },
Comment thread
posthog[bot] marked this conversation as resolved.
}).catch(() => null)
breakpoint()
if (response?.results.length && objectsEqual(values.query, query)) {
actions.setQuery(namedQuery)
}
},
})),
selectors({
defaultQuery: [
Expand Down Expand Up @@ -198,6 +241,7 @@ export const eventsSceneLogic = kea<eventsSceneLogicType>([
} else {
if (typeof queryParam === 'object') {
actions.setQuery(queryParam)
actions.nameFlagCallLookup(queryParam)
Comment thread
posthog[bot] marked this conversation as resolved.
} else {
lemonToast.error('Invalid query in URL')
console.error({ queryParam })
Expand Down
12 changes: 3 additions & 9 deletions frontend/src/scenes/scenes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand Down Expand Up @@ -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.
Expand Down
Loading