271 lines
8.0 KiB
TypeScript
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);
|