Skip to content

Commit d1b39c4

Browse files
committed
fix: funnel on profile id
This will break mixed profile_id (anon + identified) but its worth it because its "correct". This will also be fixed when we have enabled backfill profile id on a session
1 parent 3343151 commit d1b39c4

1 file changed

Lines changed: 21 additions & 29 deletions

File tree

packages/db/src/services/funnel.service.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,7 @@ export class FunnelService {
2828

2929
/**
3030
* Returns the grouping strategy for the funnel.
31-
* Note: windowFunnel is ALWAYS computed per session_id first to handle
32-
* identity changes mid-session (anonymous → logged-in).
33-
* The returned group is used for the final aggregation step.
31+
* Determines whether windowFunnel is computed per session_id or profile_id.
3432
*/
3533
getFunnelGroup(group?: string): 'profile_id' | 'session_id' {
3634
return group === 'profile_id' ? 'profile_id' : 'session_id';
@@ -46,10 +44,11 @@ export class FunnelService {
4644
}
4745

4846
/**
49-
* Builds the session-level funnel CTE.
50-
* IMPORTANT: windowFunnel is ALWAYS computed per session_id first.
51-
* This ensures identity changes mid-session (anonymous → logged-in) don't break the funnel.
52-
* The profile_id is extracted from the last event in the session using argMax.
47+
* Builds the funnel CTE.
48+
* - When group === 'session_id': windowFunnel is computed per session_id.
49+
* profile_id is resolved via argMax to handle identity changes mid-session.
50+
* - When group === 'profile_id': windowFunnel is computed directly per profile_id.
51+
* This correctly handles cross-session funnel completions.
5352
*/
5453
buildFunnelCte({
5554
projectId,
@@ -60,6 +59,7 @@ export class FunnelService {
6059
timezone,
6160
additionalSelects = [],
6261
additionalGroupBy = [],
62+
group = 'session_id',
6363
}: {
6464
projectId: string;
6565
startDate: string;
@@ -69,14 +69,18 @@ export class FunnelService {
6969
timezone: string;
7070
additionalSelects?: string[];
7171
additionalGroupBy?: string[];
72+
group?: 'session_id' | 'profile_id';
7273
}) {
7374
const funnels = this.getFunnelConditions(eventSeries);
75+
const primaryKey = group === 'profile_id' ? 'profile_id' : 'session_id';
7476

7577
return clix(this.client, timezone)
7678
.select([
77-
'session_id',
79+
primaryKey,
7880
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
79-
'argMax(profile_id, created_at) AS profile_id',
81+
...(group === 'session_id'
82+
? ['argMax(profile_id, created_at) AS profile_id']
83+
: []),
8084
...additionalSelects,
8185
])
8286
.from(TABLE_NAMES.events, false)
@@ -90,7 +94,7 @@ export class FunnelService {
9094
'IN',
9195
eventSeries.map((e) => e.name),
9296
)
93-
.groupBy(['session_id', ...additionalGroupBy]);
97+
.groupBy([primaryKey, ...additionalGroupBy]);
9498
}
9599

96100
buildSessionsCte({
@@ -248,6 +252,7 @@ export class FunnelService {
248252
timezone,
249253
additionalSelects: breakdownSelects,
250254
additionalGroupBy: breakdownGroupBy,
255+
group,
251256
});
252257

253258
if (anyFilterOnProfile || anyBreakdownOnProfile) {
@@ -276,25 +281,12 @@ export class FunnelService {
276281
const funnelQuery = clix(this.client, timezone);
277282
funnelQuery.with('session_funnel', funnelCte);
278283

279-
if (group === 'profile_id') {
280-
// For profile grouping: re-aggregate by profile_id, taking MAX level per profile.
281-
// This ensures a user who completed the funnel across multiple sessions
282-
// (or with identity change) is counted correctly.
283-
const breakdownAggregates =
284-
breakdowns.length > 0
285-
? `, ${breakdowns.map((_, index) => `any(b_${index}) AS b_${index}`).join(', ')}`
286-
: '';
287-
funnelQuery.with(
288-
'funnel',
289-
`SELECT profile_id, max(level) AS level${breakdownAggregates} FROM (SELECT * FROM session_funnel WHERE level != 0) GROUP BY profile_id`,
290-
);
291-
} else {
292-
// For session grouping: filter out level = 0 inside the CTE
293-
funnelQuery.with(
294-
'funnel',
295-
'SELECT * FROM session_funnel WHERE level != 0',
296-
);
297-
}
284+
// windowFunnel is computed per the primary key (profile_id or session_id),
285+
// so we just filter out level=0 rows — no re-aggregation needed.
286+
funnelQuery.with(
287+
'funnel',
288+
'SELECT * FROM session_funnel WHERE level != 0',
289+
);
298290

299291
funnelQuery
300292
.select<{

0 commit comments

Comments
 (0)