Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add group support to the lifecycle insight #21876

Merged
merged 32 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b99156f
frontend display dropdown
aspicer Apr 25, 2024
470928e
lifecycle
aspicer Apr 25, 2024
c106cb5
update title
aspicer Apr 25, 2024
50c4aa3
fe tests
aspicer Apr 25, 2024
2d74b4f
schema
aspicer Apr 25, 2024
fa7832c
Merge remote-tracking branch 'origin/HEAD' into aspicer/lifecycle_groups
aspicer Apr 25, 2024
8082e9e
fixing test
aspicer Apr 26, 2024
2e54125
working basic test
aspicer Apr 26, 2024
1de9e0e
group 1
aspicer Apr 26, 2024
f123280
need to remove references to person_id
aspicer Apr 26, 2024
ee6989c
Update UI snapshots for `webkit` (2)
github-actions[bot] Apr 26, 2024
dc4ef82
Update UI snapshots for `chromium` (2)
github-actions[bot] Apr 26, 2024
7ac06c0
Update query snapshots
github-actions[bot] Apr 26, 2024
16570e9
Update query snapshots
github-actions[bot] Apr 26, 2024
11566c1
merge from master
aspicer Apr 28, 2024
57215cd
refactor for events
aspicer Apr 29, 2024
282a167
Update UI snapshots for `chromium` (1)
github-actions[bot] Apr 29, 2024
ce53791
Update UI snapshots for `chromium` (1)
github-actions[bot] Apr 29, 2024
6318dc5
fix none
aspicer Apr 29, 2024
d4945ae
Merge branch 'aspicer/lifecycle_groups' of github.com:PostHog/posthog…
aspicer Apr 29, 2024
67e8b9a
lifecycle query
aspicer Apr 29, 2024
7c3d2b7
actors
aspicer Apr 30, 2024
640eb7f
trends query logic
aspicer Apr 30, 2024
ff4fdca
Merge remote-tracking branch 'origin/master' into aspicer/lifecycle_g…
aspicer Apr 30, 2024
e1649d5
types
aspicer Apr 30, 2024
32c4fab
test update
aspicer Apr 30, 2024
9db2ff4
remove comments
aspicer Apr 30, 2024
07ee6b1
Update query snapshots
github-actions[bot] Apr 30, 2024
cc008c1
Update UI snapshots for `chromium` (2)
github-actions[bot] Apr 30, 2024
7c042ff
Update UI snapshots for `chromium` (2)
github-actions[bot] Apr 30, 2024
b15b5f0
Merge branch 'master' into aspicer/lifecycle_groups
aspicer Apr 30, 2024
ecf8d5e
merge master
May 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export const queryNodeToFilter = (query: InsightQueryNode): Partial<FilterType>
Object.assign(filters, objectClean<Partial<Record<keyof BreakdownFilter, unknown>>>(query.breakdownFilter))
}

if (!isLifecycleQuery(query) && !isStickinessQuery(query)) {
if (!isStickinessQuery(query)) {
Object.assign(
filters,
objectClean({
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/queries/nodes/InsightViz/TrendsSeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { alphabet } from 'lib/utils'
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
import { AggregationSelect } from 'scenes/insights/filters/AggregationSelect'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
Expand All @@ -25,7 +26,7 @@ export function TrendsSeries(): JSX.Element | null {
const { updateQuerySource } = useActions(insightVizDataLogic(insightProps))
const { featureFlags } = useValues(featureFlagLogic)

const { groupsTaxonomicTypes } = useValues(groupsModel)
const { showGroupsOptions, groupsTaxonomicTypes } = useValues(groupsModel)

const propertiesTaxonomicGroupTypes = [
TaxonomicFilterGroupType.EventProperties,
Expand Down Expand Up @@ -57,7 +58,15 @@ export function TrendsSeries(): JSX.Element | null {
<>
{isLifecycle && (
<div className="leading-6">
Showing <b>Unique users</b> who did
<div className="flex items-center">
Showing
{showGroupsOptions ? (
<AggregationSelect className="mx-2" insightProps={insightProps} hogqlAvailable={false} />
) : (
<b> Unique users </b>
)}
who did
</div>
</div>
)}
<ActionFilter
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3220,6 +3220,10 @@
"LifecycleQuery": {
"additionalProperties": false,
"properties": {
"aggregation_group_type_index": {
"description": "Groups aggregation",
"type": "integer"
},
"dateRange": {
"$ref": "#/definitions/DateRange",
"description": "Date range for the query"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/queries/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,7 @@ export interface LifecycleQueryResponse extends QueryResponse {
results: Record<string, any>[]
}

export interface LifecycleQuery extends Omit<InsightsQueryBase, 'aggregation_group_type_index'> {
export interface LifecycleQuery extends InsightsQueryBase {
kind: NodeKind.LifecycleQuery
/** Granularity of the response. Can be one of `hour`, `day`, `week` or `month` */
interval?: IntervalType
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/scenes/insights/filters/AggregationSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'

import { groupsModel } from '~/models/groupsModel'
import { FunnelsQuery } from '~/queries/schema'
import { isFunnelsQuery, isInsightQueryNode, isLifecycleQuery, isStickinessQuery } from '~/queries/utils'
import { isFunnelsQuery, isInsightQueryNode, isStickinessQuery } from '~/queries/utils'
import { InsightLogicProps } from '~/types'

function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string {
Expand Down Expand Up @@ -52,9 +52,7 @@ export function AggregationSelect({
}

const value = getHogQLValue(
isLifecycleQuery(querySource) || isStickinessQuery(querySource)
? undefined
: querySource.aggregation_group_type_index,
isStickinessQuery(querySource) ? undefined : querySource.aggregation_group_type_index,
isFunnelsQuery(querySource) ? querySource.funnelsFilter?.funnelAggregateByHogQL : undefined
)
const onChange = (value: string): void => {
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/scenes/insights/summarizeInsight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,27 @@ describe('summarizing insights', () => {
)
).toEqual('User lifecycle based on Rageclick')
})

it('summarizes a Lifecycle insight on a different group', () => {
expect(
summarizeInsight(
null,
{
insight: InsightType.LIFECYCLE,
events: [
{
id: '$rageclick',
name: '$rageclick',
type: 'event',
order: 1,
},
],
aggregation_group_type_index: 0,
},
summaryContext
)
).toEqual('Organization lifecycle based on Rageclick')
})
})

describe('summariseInsightQuery()', () => {
Expand Down Expand Up @@ -883,6 +904,28 @@ describe('summarizing insights', () => {

expect(result).toEqual('User lifecycle based on Rageclick')
})

it('summarizes a Lifecycle insight with groups', () => {
const query: LifecycleQuery = {
kind: NodeKind.LifecycleQuery,
series: [
{
kind: NodeKind.EventsNode,
event: '$rageclick',
name: '$rageclick',
},
],
aggregation_group_type_index: 0,
}

const result = summarizeInsight(
{ kind: NodeKind.InsightVizNode, source: query } as InsightVizNode,
{},
summaryContext
)

expect(result).toEqual('Organization lifecycle based on Rageclick')
})
})

describe('summarize data table query', () => {
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/scenes/insights/summarizeInsight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ function summarizeInsightFilters(filters: AnyPartialFilterType, context: Summary
}
return summary
} else if (isLifecycleFilter(filters)) {
return `User lifecycle based on ${getDisplayNameFromEntityFilter(localFilters[0])}`
return `${capitalizeFirstLetter(
context.aggregationLabel(filters.aggregation_group_type_index, true).singular
)} lifecycle based on ${getDisplayNameFromEntityFilter(localFilters[0])}`
} else if (isFunnelsFilter(filters)) {
let summary
const linkSymbol =
Expand Down Expand Up @@ -293,7 +295,9 @@ export function summarizeInsightQuery(query: InsightQueryNode, context: SummaryC
.join(' & ')
)
} else if (isLifecycleQuery(query)) {
return `User lifecycle based on ${getDisplayNameFromEntityNode(query.series[0])}`
return `${capitalizeFirstLetter(
context.aggregationLabel(query.aggregation_group_type_index, true).singular
)} lifecycle based on ${getDisplayNameFromEntityNode(query.series[0])}`
}
return ''
}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/scenes/trends/trendsDataLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('trendsDataLogic', () => {
insightVizDataLogic.findMounted(insightProps)?.actions.updateQuerySource(query)
builtDataNodeLogic.actions.loadDataSuccess(insight)
}).toMatchValues({
labelGroupType: 'people',
indexedResults: [
expect.objectContaining({
count: -50255.0,
Expand All @@ -137,6 +138,14 @@ describe('trendsDataLogic', () => {
}),
],
})

query.aggregation_group_type_index = 1
await expectLogic(logic, () => {
insightVizDataLogic.findMounted(insightProps)?.actions.updateQuerySource(query)
builtDataNodeLogic.actions.loadDataSuccess(insight)
}).toMatchValues({
labelGroupType: 1,
})
})
})
})
Expand Down
22 changes: 15 additions & 7 deletions frontend/src/scenes/trends/trendsDataLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
isOtherBreakdown,
} from 'scenes/insights/utils'

import { EntityNode } from '~/queries/schema'
import { EntityNode, LifecycleQuery } from '~/queries/schema'
import {
ChartDisplayType,
CountPerActorMathType,
Expand Down Expand Up @@ -44,6 +44,7 @@ export const trendsDataLogic = kea<trendsDataLogicType>([
values: [
insightVizDataLogic(props),
[
'querySource',
'insightData',
'insightDataLoading',
'series',
Expand Down Expand Up @@ -149,13 +150,20 @@ export const trendsDataLogic = kea<trendsDataLogicType>([
],

labelGroupType: [
(s) => [s.series],
(series): 'people' | 'none' | number => {
(s) => [s.series, s.querySource, s.isLifecycle],
(series, querySource, isLifecycle): 'people' | 'none' | number => {
// Find the commonly shared aggregation group index if there is one.
const firstAggregationGroupTypeIndex = series?.[0]?.math_group_type_index
return series?.every((eOrA) => eOrA?.math_group_type_index === firstAggregationGroupTypeIndex)
? firstAggregationGroupTypeIndex ?? 'people' // if undefined, will resolve to 'people' label
: 'none' // mixed group types
let firstAggregationGroupTypeIndex: 'people' | 'none' | number | undefined
if (isLifecycle) {
firstAggregationGroupTypeIndex = (querySource as LifecycleQuery)?.aggregation_group_type_index
} else {
firstAggregationGroupTypeIndex = series?.[0]?.math_group_type_index
if (!series?.every((eOrA) => eOrA?.math_group_type_index === firstAggregationGroupTypeIndex)) {
return 'none' // mixed group types
}
}

return firstAggregationGroupTypeIndex ?? 'people'
},
],

Expand Down
6 changes: 6 additions & 0 deletions posthog/hogql_queries/insights/insight_actors_query_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
StickinessQuery,
TrendsQuery,
FunnelsQuery,
LifecycleQuery,
)
from posthog.types import InsightActorsQueryNode

Expand Down Expand Up @@ -89,6 +90,11 @@ def group_type_index(self) -> int | None:
assert isinstance(self.query.source, FunnelsQuery)
return self.query.source.aggregation_group_type_index

if isinstance(self.source_runner, LifecycleQueryRunner):
# Lifecycle Query uses a plain InsightActorsQuery
assert isinstance(self.query.source, LifecycleQuery)
return self.query.source.aggregation_group_type_index

if (
isinstance(self.source_runner, StickinessQueryRunner) and isinstance(self.query.source, StickinessQuery)
) or (isinstance(self.source_runner, TrendsQueryRunner) and isinstance(self.query.source, TrendsQuery)):
Expand Down
40 changes: 34 additions & 6 deletions posthog/hogql_queries/insights/lifecycle_query_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery:
ORDER BY status, start_of_period
UNION ALL
SELECT
start_of_period, count(DISTINCT person_id) AS counts, status
start_of_period, count(DISTINCT actor_id) AS counts, status
FROM {events_query}
GROUP BY start_of_period, status
)
Expand All @@ -94,7 +94,7 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery:
def to_actors_query(
self, day: Optional[str] = None, status: Optional[str] = None
) -> ast.SelectQuery | ast.SelectUnionQuery:
with self.timings.measure("persons_query"):
with self.timings.measure("actors_query"):
exprs = []
if day is not None:
exprs.append(
Expand All @@ -116,7 +116,7 @@ def to_actors_query(
)

return parse_select(
"SELECT DISTINCT person_id FROM {events_query} WHERE {where}",
"SELECT DISTINCT actor_id FROM {events_query} WHERE {where}",
placeholders={
"events_query": self.events_query,
"where": ast.And(exprs=exprs) if len(exprs) > 0 else ast.Constant(value=1),
Expand Down Expand Up @@ -270,20 +270,46 @@ def event_filter(self) -> ast.Expr:
for property in self.team.test_account_filters:
event_filters.append(property_to_expr(property, self.team))

if self.has_group_type:
event_filters.append(
ast.Not(
expr=ast.Call(
name="has",
args=[
ast.Array(exprs=[ast.Constant(value="")]),
self.target_field,
],
),
),
)

if len(event_filters) == 0:
return ast.Constant(value=True)
elif len(event_filters) == 1:
return event_filters[0]
else:
return ast.And(exprs=event_filters)

@property
def has_group_type(self) -> bool:
return self.group_type_index is not None and 0 <= self.group_type_index <= 4

@property
def group_type_index(self) -> int | None:
return self.query.aggregation_group_type_index

@property
def target_field(self):
if self.has_group_type:
return ast.Field(chain=["events", f"$group_{self.group_type_index}"])
return ast.Field(chain=["person_id"])

@cached_property
def events_query(self):
with self.timings.measure("events_query"):
events_query = parse_select(
"""
SELECT
events.person_id as person_id,
min(events.person.created_at) AS created_at,
arraySort(groupUniqArray({trunc_timestamp})) AS all_activity,
arrayPopBack(arrayPushFront(all_activity, {trunc_created_at})) as previous_activity,
Expand All @@ -295,13 +321,15 @@ def events_query(self):
arrayConcat(arrayZip(all_activity, initial_status), arrayZip(dormant_periods, dormant_label)) as temp_concat,
arrayJoin(temp_concat) as period_status_pairs,
period_status_pairs.1 as start_of_period,
period_status_pairs.2 as status
period_status_pairs.2 as status,
{target}
FROM events
WHERE {event_filter}
GROUP BY person_id
GROUP BY actor_id
""",
placeholders={
**self.query_date_range.to_placeholders(),
"target": ast.Alias(alias="actor_id", expr=self.target_field),
"event_filter": self.event_filter,
"trunc_timestamp": self.query_date_range.date_to_start_of_interval_hogql(
ast.Field(chain=["events", "timestamp"])
Expand Down