Files
stats/packages/db/src/services/funnel.service.ts
2025-04-02 11:21:51 +02:00

271 lines
8.0 KiB
TypeScript

import { ifNaN } from '@openpanel/common';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { last, reverse } from 'ramda';
import { escape } from 'sqlstring';
import { ch } from '../clickhouse/client';
import { TABLE_NAMES } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import {
getEventFiltersWhereClause,
getSelectPropertyKey,
} from './chart.service';
export class FunnelService {
constructor(private client: typeof ch) {}
private getFunnelGroup(group?: string) {
return group === 'profile_id'
? [`COALESCE(nullIf(s.profile_id, ''), profile_id)`, 'profile_id']
: ['session_id', 'session_id'];
}
private getFunnelConditions(events: IChartEvent[]) {
return events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
}
private fillFunnel(
funnel: { level: number; count: number }[],
steps: number,
) {
const filled = Array.from({ length: steps }, (_, index) => {
const level = index + 1;
const matchingResult = funnel.find((res) => res.level === level);
return {
level,
count: matchingResult ? matchingResult.count : 0,
};
});
// Accumulate counts from top to bottom of the funnel
for (let i = filled.length - 1; i >= 0; i--) {
const step = filled[i];
const prevStep = filled[i + 1];
// If there's a previous step, add the count to the current step
if (step && prevStep) {
step.count += prevStep.count;
}
}
return filled.reverse();
}
toSeries(
funnel: { level: number; count: number; [key: string]: any }[],
breakdowns: { name: string }[] = [],
) {
if (!breakdowns.length) {
return [
funnel.map((f) => ({
level: f.level,
count: f.count,
id: 'none',
breakdowns: [],
})),
];
}
// Group by breakdown values
const series = funnel.reduce(
(acc, f) => {
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
if (!acc[key]) {
acc[key] = [];
}
acc[key]!.push({
id: key,
breakdowns: breakdowns.map((b, index) => f[`b_${index}`]),
level: f.level,
count: f.count,
});
return acc;
},
{} as Record<
string,
{
id: string;
breakdowns: string[];
level: number;
count: number;
}[]
>,
);
return Object.values(series);
}
async getFunnel({
projectId,
startDate,
endDate,
events,
funnelWindow = 24,
funnelGroup,
breakdowns = [],
}: IChartInput) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (events.length === 0) {
throw new Error('events are required');
}
const funnelWindowSeconds = funnelWindow * 3600;
const group = this.getFunnelGroup(funnelGroup);
const funnels = this.getFunnelConditions(events);
// Create the funnel CTE
const funnelCte = clix(this.client)
.select([
`${group[0]} AS ${group[1]}`,
...breakdowns.map(
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
),
`windowFunnel(${funnelWindowSeconds}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level`,
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate),
clix.datetime(endDate),
])
.where(
'name',
'IN',
events.map((e) => e.name),
)
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
// Create the sessions CTE if needed
const sessionsCte =
group[0] !== 'session_id'
? clix(this.client)
.select(['profile_id', 'id'])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate),
clix.datetime(endDate),
])
: null;
// Base funnel query with CTEs
const funnelQuery = clix(this.client);
if (sessionsCte) {
funnelCte.join('sessions s', 's.id = session_id');
funnelQuery.with('sessions', sessionsCte);
}
funnelQuery.with('funnel', funnelCte);
funnelQuery
.select<{
level: number;
count: number;
[key: string]: any;
}>([
'level',
...breakdowns.map((b, index) => `b_${index}`),
'count() as count',
])
.from('funnel')
.where('level', '!=', 0)
.groupBy(['level', ...breakdowns.map((b, index) => `b_${index}`)])
.orderBy('level', 'DESC');
const funnelData = await funnelQuery.execute();
const funnelSeries = this.toSeries(funnelData, breakdowns);
return funnelSeries
.map((data) => {
const maxLevel = events.length;
const filledFunnelRes = this.fillFunnel(
data.map((d) => ({ level: d.level, count: d.count })),
maxLevel,
);
const totalSessions = last(filledFunnelRes)?.count ?? 0;
const steps = reverse(filledFunnelRes)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
const next = list[index + 1];
const event = events[item.level - 1]!;
return [
...acc,
{
event: {
...event,
displayName: event.displayName ?? event.name,
},
count: item.count,
percent: (item.count / totalSessions) * 100,
dropoffCount: next ? item.count - next.count : null,
dropoffPercent: next
? ((item.count - next.count) / item.count) * 100
: null,
previousCount: prev.count,
nextCount: next?.count ?? null,
},
];
},
[] as {
event: IChartEvent & { displayName: string };
count: number;
percent: number;
dropoffCount: number | null;
dropoffPercent: number | null;
previousCount: number;
nextCount: number | null;
}[],
)
.map((step, index, list) => {
return {
...step,
percent: ifNaN(step.percent, 0),
dropoffPercent: ifNaN(step.dropoffPercent, 0),
isHighestDropoff: (() => {
// Skip if current step has no dropoff
if (!step?.dropoffCount) return false;
// Get maximum dropoff count, excluding 0s
const maxDropoff = Math.max(
...list
.map((s) => s.dropoffCount || 0)
.filter((count) => count > 0),
);
// Check if this is the first step with the highest dropoff
return (
step.dropoffCount === maxDropoff &&
list.findIndex((s) => s.dropoffCount === maxDropoff) === index
);
})(),
};
});
return {
id: data[0]?.id ?? 'none',
breakdowns: data[0]?.breakdowns ?? [],
steps,
totalSessions,
lastStep: last(steps)!,
mostDropoffsStep: steps.find((step) => step.isHighestDropoff)!,
};
})
.sort((a, b) => {
const aTotal = a.steps.reduce((acc, step) => acc + step.count, 0);
const bTotal = b.steps.reduce((acc, step) => acc + step.count, 0);
return bTotal - aTotal;
});
}
}
export const funnelService = new FunnelService(ch);