Files
stats/apps/dashboard/src/server/api/routers/chart.ts
Carl-Gerhard Lindesvärd e6c0bc2ec8 rename mixan to OPENPANEL!
2024-03-11 13:40:46 +01:00

512 lines
13 KiB
TypeScript

import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { average, max, min, round, sum } from '@/utils/math';
import {
chQuery,
createSqlBuilder,
formatClickhouseDate,
getEventFiltersWhereClause,
} from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
import { z } from 'zod';
import {
getChartData,
getChartStartEndDate,
getDatesFromRange,
withFormula,
} from './chart.helpers';
async function getFunnelData({ projectId, ...payload }: IChartInput) {
const { startDate, endDate } = getChartStartEndDate(payload);
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = '${event.name}'`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE (project_id = '${projectId}' AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
GROUP BY session_id;`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC;`;
const [funnelRes, sessionRes] = await Promise.all([
chQuery<{ level: number; count: number }>(sql),
chQuery<{ count: number }>(
`SELECT count(name) as count FROM events WHERE project_id = '${projectId}' AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
),
]);
console.log('Funnel SQL: ', sql);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
level: payload.events.length,
count: 0,
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
if (diff > 1) {
acc.push(
...reverse(
repeat({}, diff - 1).map((_, index) => ({
count: acc[acc.length - 1]?.count ?? 0,
level: item.level + index + 1,
}))
)
);
}
return [
...acc,
{
count: item.count + (acc[acc.length - 1]?.count ?? 0),
level: item.level,
},
];
},
[] as typeof funnelRes
);
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
return [
...acc,
{
event: payload.events[item.level - 1]!,
before: prev.count,
current: item.count,
dropoff: {
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
},
percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100,
},
];
},
[] as {
event: IChartEvent;
before: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number;
prevPercent: number;
}[]
);
return {
totalSessions,
steps,
};
}
type PreviousValue = {
value: number;
diff: number | null;
state: 'positive' | 'negative' | 'neutral';
} | null;
interface Metrics {
sum: number;
average: number;
min: number;
max: number;
previous: {
sum: PreviousValue;
average: PreviousValue;
min: PreviousValue;
max: PreviousValue;
};
}
export interface IChartSerie {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
}
export interface FinalChart {
events: IChartInput['events'];
series: IChartSerie[];
metrics: Metrics;
}
export const chartRouter = createTRPCRouter({
events: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ name: string }>(
`SELECT DISTINCT name FROM events WHERE project_id = '${projectId}'`
);
return [
{
name: '*',
},
...events,
];
}),
properties: protectedProcedure
.input(z.object({ event: z.string().optional(), projectId: z.string() }))
.query(async ({ input: { projectId, event } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from events where ${
event && event !== '*' ? `name = '${event}' AND ` : ''
} project_id = '${projectId}';`
);
const properties = events
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push(
'name',
'path',
'referrer',
'referrer_name',
'duration',
'created_at',
'country',
'city',
'region',
'os',
'os_version',
'browser',
'browser_version',
'device',
'brand',
'model'
);
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
// TODO: Make this private
values: publicProcedure
.input(
z.object({
event: z.string(),
property: z.string(),
projectId: z.string(),
})
)
.query(async ({ input: { event, property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.where.project_id = `project_id = '${projectId}'`;
if (event !== '*') {
sb.where.event = `name = '${event}'`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace('.*.', '.%.')}')) as values`;
} else {
sb.select.values = `${property} as values`;
}
const events = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof events) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(events);
return {
values,
};
}),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
return getFunnelData(input);
}),
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const { startDate, endDate } = getChartStartEndDate(input);
let diff = 0;
switch (input.range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
const promises = [getSeriesFromEvents(input)];
if (input.previous) {
promises.push(
getSeriesFromEvents({
...input,
...{
startDate: new Date(
new Date(startDate).getTime() - diff
).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
},
})
);
}
const result = await Promise.all(promises);
const series = result[0]!;
const previousSeries = result[1];
const final: FinalChart = {
events: input.events,
series: series.map((serie, index) => {
const previousSerie = previousSeries?.[index];
const metrics = {
sum: sum(serie.data.map((item) => item.count)),
average: round(average(serie.data.map((item) => item.count)), 2),
min: min(serie.data.map((item) => item.count)),
max: max(serie.data.map((item) => item.count)),
};
return {
name: serie.name,
event: serie.event,
metrics: {
...metrics,
previous: {
sum: getPreviousMetric(
metrics.sum,
previousSerie
? sum(previousSerie?.data.map((item) => item.count))
: null
),
average: getPreviousMetric(
metrics.average,
previousSerie
? round(
average(previousSerie?.data.map((item) => item.count)),
2
)
: null
),
min: getPreviousMetric(
metrics.sum,
previousSerie
? min(previousSerie?.data.map((item) => item.count))
: null
),
max: getPreviousMetric(
metrics.sum,
previousSerie
? max(previousSerie?.data.map((item) => item.count))
: null
),
},
},
data: serie.data.map((item, index) => ({
date: item.date,
count: item.count ?? 0,
label: item.label,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count ?? 0,
previousSerie?.data[index]?.count ?? null
)
: null,
})),
};
}),
metrics: {
sum: 0,
average: 0,
min: 0,
max: 0,
previous: {
sum: null,
average: null,
min: null,
max: null,
},
},
};
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
final.metrics.average = round(
average(final.series.map((item) => item.metrics.average)),
2
);
final.metrics.min = min(final.series.map((item) => item.metrics.min));
final.metrics.max = max(final.series.map((item) => item.metrics.max));
final.metrics.previous = {
sum: getPreviousMetric(
sum(final.series.map((item) => item.metrics.sum)),
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
),
average: getPreviousMetric(
round(average(final.series.map((item) => item.metrics.average)), 2),
round(
average(
final.series.map(
(item) => item.metrics.previous.average?.value ?? 0
)
),
2
)
),
min: getPreviousMetric(
min(final.series.map((item) => item.metrics.min)),
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
),
max: getPreviousMetric(
max(final.series.map((item) => item.metrics.max)),
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
),
};
final.series = final.series.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
return sumB - sumA;
} else {
return b.metrics[input.metric] - a.metrics[input.metric];
}
});
// await new Promise((res) => {
// setTimeout(() => {
// res();
// }, 100);
// });
return final;
}),
});
function getPreviousMetric(
current: number,
previous: number | null
): PreviousValue {
if (previous === null) {
return null;
}
const diff = round(
((current > previous
? current / previous
: current < previous
? previous / current
: 0) -
1) *
100,
1
);
return {
diff:
Number.isNaN(diff) || !Number.isFinite(diff) || current === previous
? null
: diff,
state:
current > previous
? 'positive'
: current < previous
? 'negative'
: 'neutral',
value: previous,
};
}
async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
? {
startDate: input.startDate,
endDate: input.endDate,
}
: getDatesFromRange(input.range);
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartData({
...input,
startDate,
endDate,
event,
})
)
)
).flat();
return withFormula(input, series);
}