fix: handle revenue better on overview (and remove it from top pages)

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-15 22:10:21 +01:00
parent 4e7dc16619
commit a2a53cf9f7

View File

@@ -7,6 +7,28 @@ import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder'; import { clix } from '../clickhouse/query-builder';
import { getEventFiltersWhereClause } from './chart.service'; import { getEventFiltersWhereClause } from './chart.service';
// Constants
const ROLLUP_DATE_PREFIX = '1970-01-01';
const COLUMN_PREFIX_MAP: Record<string, string> = {
region: 'country',
city: 'country',
browser_version: 'browser',
os_version: 'os',
};
// Types
type MetricsRow = {
bounce_rate: number;
unique_visitors: number;
total_sessions: number;
avg_session_duration: number;
total_screen_views: number;
views_per_session: number;
};
type MetricsSeriesRow = MetricsRow & { date: string; total_revenue: number };
export const zGetMetricsInput = z.object({ export const zGetMetricsInput = z.object({
projectId: z.string(), projectId: z.string(),
filters: z.array(z.any()), filters: z.array(z.any()),
@@ -85,11 +107,112 @@ export type IGetTopGenericInput = z.infer<typeof zGetTopGenericInput> & {
export class OverviewService { export class OverviewService {
constructor(private client: typeof ch) {} constructor(private client: typeof ch) {}
// Helper methods
private isRollupRow(date: string): boolean {
return date.startsWith(ROLLUP_DATE_PREFIX);
}
private getFillConfig(interval: string, startDate: string, endDate: string) {
const useDateOnly = ['month', 'week'].includes(interval);
return {
from: clix.toStartOf(
clix.datetime(startDate, useDateOnly ? 'toDate' : 'toDateTime'),
interval as any,
),
to: clix.datetime(endDate, useDateOnly ? 'toDate' : 'toDateTime'),
step: clix.toInterval('1', interval as any),
};
}
private createRevenueQuery({
projectId,
startDate,
endDate,
interval,
timezone,
filters,
}: {
projectId: string;
startDate: string;
endDate: string;
interval: string;
timezone: string;
filters: IChartEventFilter[];
}) {
return clix(this.client, timezone)
.select<{ date: string; total_revenue: number }>([
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
'sum(revenue) AS total_revenue',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', projectId)
.where('name', '=', 'revenue')
.where('revenue', '>', 0)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['date'])
.rollup()
.transform({
date: (item) => new Date(item.date).toISOString(),
});
}
private mergeRevenueIntoSeries<T extends { date: string }>(
series: T[],
revenueData: { date: string; total_revenue: number }[],
): (T & { total_revenue: number })[] {
const revenueByDate = new Map(
revenueData
.filter((r) => !this.isRollupRow(r.date))
.map((r) => [r.date, r.total_revenue]),
);
return series.map((row) => ({
...row,
total_revenue: revenueByDate.get(row.date) ?? 0,
}));
}
private getOverallRevenue(
revenueData: { date: string; total_revenue: number }[],
): number {
return (
revenueData.find((r) => this.isRollupRow(r.date))?.total_revenue ?? 0
);
}
private withDistinctSessionsIfNeeded<T>(
query: ReturnType<typeof clix>,
params: {
filters: IChartEventFilter[];
projectId: string;
startDate: string;
endDate: string;
timezone: string;
},
): ReturnType<typeof clix> {
if (!this.isPageFilter(params.filters)) {
query.rawWhere(this.getRawWhereClause('sessions', params.filters));
return query;
}
return clix(this.client, params.timezone)
.with('distinct_sessions', this.getDistinctSessions(params))
.merge(query)
.where(
'id',
'IN',
clix.exp('(SELECT session_id FROM distinct_sessions)'),
);
}
isPageFilter(filters: IChartEventFilter[]) { isPageFilter(filters: IChartEventFilter[]) {
return filters.some((filter) => filter.name === 'path' && filter.value); return filters.some((filter) => filter.name === 'path' && filter.value);
} }
getMetrics({ async getMetrics({
projectId, projectId,
filters, filters,
startDate, startDate,
@@ -116,15 +239,128 @@ export class OverviewService {
views_per_session: number; views_per_session: number;
total_revenue: number; total_revenue: number;
}[]; }[];
}> {
return this.isPageFilter(filters)
? this.getMetricsWithPageFilter({
projectId,
filters,
startDate,
endDate,
interval,
timezone,
})
: this.getMetricsFromSessions({
projectId,
filters,
startDate,
endDate,
interval,
timezone,
});
}
private async getMetricsFromSessions({
projectId,
filters,
startDate,
endDate,
interval,
timezone,
}: IGetMetricsInput): Promise<{
metrics: MetricsRow & { total_revenue: number };
series: MetricsSeriesRow[];
}> { }> {
const where = this.getRawWhereClause('sessions', filters); const where = this.getRawWhereClause('sessions', filters);
if (this.isPageFilter(filters)) { const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Session metrics query
const sessionQuery = clix(this.client, timezone)
.select<{
date: string;
bounce_rate: number;
unique_visitors: number;
total_sessions: number;
avg_session_duration: number;
total_screen_views: number;
views_per_session: number;
}>([
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
'uniqIf(profile_id, sign > 0) AS unique_visitors',
'sum(sign) AS total_sessions',
'round(avgIf(duration, duration > 0 AND sign > 0), 2) / 1000 AS _avg_session_duration',
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'sum(sign * screen_view_count) AS total_screen_views',
'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session',
])
.from('sessions')
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.where('project_id', '=', projectId)
.rawWhere(where)
.groupBy(['date'])
.having('sum(sign)', '>', 0)
.rollup()
.orderBy('date', 'ASC')
.fill(fillConfig.from, fillConfig.to, fillConfig.step)
.transform({
date: (item) => new Date(item.date).toISOString(),
});
// Revenue query
const revenueQuery = this.createRevenueQuery({
projectId,
startDate,
endDate,
interval,
timezone,
filters,
});
// Execute both queries in parallel and merge results
const [sessionRes, revenueRes] = await Promise.all([
sessionQuery.execute(),
revenueQuery.execute(),
]);
const overallRevenue = this.getOverallRevenue(revenueRes);
const series = this.mergeRevenueIntoSeries(sessionRes.slice(1), revenueRes);
return {
metrics: {
bounce_rate: sessionRes[0]?.bounce_rate ?? 0,
unique_visitors: sessionRes[0]?.unique_visitors ?? 0,
total_sessions: sessionRes[0]?.total_sessions ?? 0,
avg_session_duration: sessionRes[0]?.avg_session_duration ?? 0,
total_screen_views: sessionRes[0]?.total_screen_views ?? 0,
views_per_session: sessionRes[0]?.views_per_session ?? 0,
total_revenue: overallRevenue,
},
series,
};
}
private async getMetricsWithPageFilter({
projectId,
filters,
startDate,
endDate,
interval,
timezone,
}: IGetMetricsInput): Promise<{
metrics: MetricsRow & { total_revenue: number };
series: MetricsSeriesRow[];
}> {
const where = this.getRawWhereClause('sessions', filters);
const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Session aggregation with bounce rates // Session aggregation with bounce rates
const sessionAggQuery = clix(this.client, timezone) const sessionAggQuery = clix(this.client, timezone)
.select([ .select([
`${clix.toStartOf('created_at', interval, timezone)} AS date`, `${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
'round((countIf(is_bounce = 1 AND sign = 1) * 100.) / countIf(sign = 1), 2) AS bounce_rate', 'round((countIf(is_bounce = 1 AND sign = 1) * 100.) / countIf(sign = 1), 2) AS bounce_rate',
'sum(revenue * sign) AS total_revenue',
]) ])
.from(TABLE_NAMES.sessions, true) .from(TABLE_NAMES.sessions, true)
.where('sign', '=', 1) .where('sign', '=', 1)
@@ -156,10 +392,11 @@ export class OverviewService {
// Use toDate for month/week intervals, toDateTime for others // Use toDate for month/week intervals, toDateTime for others
const rollupDate = const rollupDate =
interval === 'month' || interval === 'week' interval === 'month' || interval === 'week'
? clix.date('1970-01-01') ? clix.date(ROLLUP_DATE_PREFIX)
: clix.datetime('1970-01-01 00:00:00'); : clix.datetime(`${ROLLUP_DATE_PREFIX} 00:00:00`);
return clix(this.client, timezone) // Main metrics query (without revenue)
const mainQuery = clix(this.client, timezone)
.with('session_agg', sessionAggQuery) .with('session_agg', sessionAggQuery)
.with( .with(
'overall_bounce_rate', 'overall_bounce_rate',
@@ -169,16 +406,9 @@ export class OverviewService {
.where('date', '=', rollupDate), .where('date', '=', rollupDate),
) )
.with( .with(
'overall_total_revenue', 'daily_session_stats',
clix(this.client, timezone) clix(this.client, timezone)
.select(['total_revenue']) .select(['date', 'bounce_rate'])
.from('session_agg')
.where('date', '=', rollupDate),
)
.with(
'daily_stats',
clix(this.client, timezone)
.select(['date', 'bounce_rate', 'total_revenue'])
.from('session_agg') .from('session_agg')
.where('date', '!=', rollupDate), .where('date', '!=', rollupDate),
) )
@@ -191,30 +421,26 @@ export class OverviewService {
avg_session_duration: number; avg_session_duration: number;
total_screen_views: number; total_screen_views: number;
views_per_session: number; views_per_session: number;
total_revenue: number;
overall_unique_visitors: number; overall_unique_visitors: number;
overall_total_sessions: number; overall_total_sessions: number;
overall_bounce_rate: number; overall_bounce_rate: number;
overall_total_revenue: number;
}>([ }>([
`${clix.toStartOf('e.created_at', interval)} AS date`, `${clix.toStartOf('e.created_at', interval as any)} AS date`,
'ds.bounce_rate as bounce_rate', 'dss.bounce_rate as bounce_rate',
'uniq(e.profile_id) AS unique_visitors', 'uniq(e.profile_id) AS unique_visitors',
'uniq(e.session_id) AS total_sessions', 'uniq(e.session_id) AS total_sessions',
'round(avgIf(duration, duration > 0), 2) / 1000 AS _avg_session_duration', 'round(avgIf(duration, duration > 0), 2) / 1000 AS _avg_session_duration',
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration', 'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'count(*) AS total_screen_views', 'count(*) AS total_screen_views',
'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session', 'round((count(*) * 1.) / uniq(e.session_id), 2) AS views_per_session',
'ds.total_revenue AS total_revenue',
'(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors', '(SELECT unique_visitors FROM overall_unique_visitors) AS overall_unique_visitors',
'(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions', '(SELECT total_sessions FROM overall_unique_visitors) AS overall_total_sessions',
'(SELECT bounce_rate FROM overall_bounce_rate) AS overall_bounce_rate', '(SELECT bounce_rate FROM overall_bounce_rate) AS overall_bounce_rate',
'(SELECT total_revenue FROM overall_total_revenue) AS overall_total_revenue',
]) ])
.from(`${TABLE_NAMES.events} AS e`) .from(`${TABLE_NAMES.events} AS e`)
.leftJoin( .leftJoin(
'daily_stats AS ds', 'daily_session_stats AS dss',
`${clix.toStartOf('e.created_at', interval)} = ds.date`, `${clix.toStartOf('e.created_at', interval as any)} = dss.date`,
) )
.where('e.project_id', '=', projectId) .where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view') .where('e.name', '=', 'screen_view')
@@ -223,129 +449,55 @@ export class OverviewService {
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.rawWhere(this.getRawWhereClause('events', filters)) .rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['date', 'ds.bounce_rate', 'ds.total_revenue']) .groupBy(['date', 'dss.bounce_rate'])
.orderBy('date', 'ASC') .orderBy('date', 'ASC')
.fill( .fill(fillConfig.from, fillConfig.to, fillConfig.step)
clix.toStartOf(
clix.datetime(
startDate,
['month', 'week'].includes(interval) ? 'toDate' : 'toDateTime',
),
interval,
),
clix.datetime(
endDate,
['month', 'week'].includes(interval) ? 'toDate' : 'toDateTime',
),
clix.toInterval('1', interval),
)
.transform({ .transform({
date: (item) => new Date(item.date).toISOString(), date: (item) => new Date(item.date).toISOString(),
}) });
.execute()
.then((res) => { // Revenue query
const anyRowWithData = res.find( const revenueQuery = this.createRevenueQuery({
projectId,
startDate,
endDate,
interval,
timezone,
filters,
});
// Execute both queries in parallel and merge results
const [mainRes, revenueRes] = await Promise.all([
mainQuery.execute(),
revenueQuery.execute(),
]);
const overallRevenue = this.getOverallRevenue(revenueRes);
const series = this.mergeRevenueIntoSeries(mainRes, revenueRes);
const anyRowWithData = mainRes.find(
(item) => (item) =>
item.overall_bounce_rate !== null || item.overall_bounce_rate !== null ||
item.overall_total_sessions !== null || item.overall_total_sessions !== null ||
item.overall_unique_visitors !== null || item.overall_unique_visitors !== null,
item.overall_total_revenue !== null,
); );
return { return {
metrics: { metrics: {
bounce_rate: anyRowWithData?.overall_bounce_rate ?? 0, bounce_rate: anyRowWithData?.overall_bounce_rate ?? 0,
unique_visitors: anyRowWithData?.overall_unique_visitors ?? 0, unique_visitors: anyRowWithData?.overall_unique_visitors ?? 0,
total_sessions: anyRowWithData?.overall_total_sessions ?? 0, total_sessions: anyRowWithData?.overall_total_sessions ?? 0,
avg_session_duration: average( avg_session_duration: average(
res.map((item) => item.avg_session_duration), mainRes.map((item) => item.avg_session_duration),
),
total_screen_views: sum(
res.map((item) => item.total_screen_views),
), ),
total_screen_views: sum(mainRes.map((item) => item.total_screen_views)),
views_per_session: average( views_per_session: average(
res.map((item) => item.views_per_session), mainRes.map((item) => item.views_per_session),
), ),
total_revenue: anyRowWithData?.overall_total_revenue ?? 0, total_revenue: overallRevenue,
}, },
series: res.map( series,
omit([
'overall_bounce_rate',
'overall_unique_visitors',
'overall_total_sessions',
'overall_total_revenue',
]),
),
}; };
});
}
const query = clix(this.client, timezone)
.select<{
date: string;
bounce_rate: number;
unique_visitors: number;
total_sessions: number;
avg_session_duration: number;
total_screen_views: number;
views_per_session: number;
total_revenue: number;
}>([
`${clix.toStartOf('created_at', interval, timezone)} AS date`,
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
'uniqIf(profile_id, sign > 0) AS unique_visitors',
'sum(sign) AS total_sessions',
'round(avgIf(duration, duration > 0 AND sign > 0), 2) / 1000 AS _avg_session_duration',
'if(isNaN(_avg_session_duration), 0, _avg_session_duration) AS avg_session_duration',
'sum(sign * screen_view_count) AS total_screen_views',
'round(sum(sign * screen_view_count) * 1.0 / sum(sign), 2) AS views_per_session',
'sum(revenue * sign) AS total_revenue',
])
.from('sessions')
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.where('project_id', '=', projectId)
.rawWhere(where)
.groupBy(['date'])
.having('sum(sign)', '>', 0)
.rollup()
.orderBy('date', 'ASC')
.fill(
clix.toStartOf(
clix.datetime(
startDate,
['month', 'week'].includes(interval) ? 'toDate' : 'toDateTime',
),
interval,
),
clix.datetime(
endDate,
['month', 'week'].includes(interval) ? 'toDate' : 'toDateTime',
),
clix.toInterval('1', interval),
)
.transform({
date: (item) => new Date(item.date).toISOString(),
});
return query.execute().then((res) => {
// First row is the rollup row containing the total values
return {
metrics: {
bounce_rate: res[0]?.bounce_rate ?? 0,
unique_visitors: res[0]?.unique_visitors ?? 0,
total_sessions: res[0]?.total_sessions ?? 0,
avg_session_duration: res[0]?.avg_session_duration ?? 0,
total_screen_views: res[0]?.total_screen_views ?? 0,
views_per_session: res[0]?.views_per_session ?? 0,
total_revenue: res[0]?.total_revenue ?? 0,
},
series: res
.slice(1)
.map(omit(['overall_bounce_rate', 'overall_unique_visitors'])),
};
});
} }
getRawWhereClause(type: 'events' | 'sessions', filters: IChartEventFilter[]) { getRawWhereClause(type: 'events' | 'sessions', filters: IChartEventFilter[]) {
@@ -368,12 +520,6 @@ export class OverviewService {
} }
return item; return item;
}), }),
// .filter((item) => {
// if (this.isPageFilter(filters) && type === 'sessions') {
// return item.name !== 'entry_path' && item.name !== 'entry_origin';
// }
// return true;
// }),
); );
return Object.values(where).join(' AND '); return Object.values(where).join(' AND ');
@@ -414,7 +560,6 @@ export class OverviewService {
'entry_path', 'entry_path',
'entry_origin', 'entry_origin',
'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate', 'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate',
'sum(revenue * sign) as revenue',
]) ])
.from(TABLE_NAMES.sessions, true) .from(TABLE_NAMES.sessions, true)
.where('sign', '=', 1) .where('sign', '=', 1)
@@ -446,7 +591,6 @@ export class OverviewService {
'p.avg_duration', 'p.avg_duration',
'p.count as sessions', 'p.count as sessions',
'b.bounce_rate', 'b.bounce_rate',
'coalesce(b.revenue, 0) as revenue',
]) ])
.from('page_stats p', false) .from('page_stats p', false)
.leftJoin( .leftJoin(
@@ -469,16 +613,6 @@ export class OverviewService {
limit = 10, limit = 10,
timezone, timezone,
}: IGetTopEntryExitInput) { }: IGetTopEntryExitInput) {
const where = this.getRawWhereClause('sessions', filters);
const distinctSessionQuery = this.getDistinctSessions({
projectId,
filters,
startDate,
endDate,
timezone,
});
const offset = (cursor - 1) * limit; const offset = (cursor - 1) * limit;
const query = clix(this.client, timezone) const query = clix(this.client, timezone)
@@ -503,25 +637,19 @@ export class OverviewService {
clix.datetime(startDate, 'toDateTime'), clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.rawWhere(where)
.groupBy([`${mode}_origin`, `${mode}_path`]) .groupBy([`${mode}_origin`, `${mode}_path`])
.having('sum(sign)', '>', 0) .having('sum(sign)', '>', 0)
.orderBy('sessions', 'DESC') .orderBy('sessions', 'DESC')
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
let mainQuery = query; const mainQuery = this.withDistinctSessionsIfNeeded(query, {
projectId,
if (this.isPageFilter(filters)) { filters,
mainQuery = clix(this.client, timezone) startDate,
.with('distinct_sessions', distinctSessionQuery) endDate,
.merge(query) timezone,
.where( });
'id',
'IN',
clix.exp('(SELECT session_id FROM distinct_sessions)'),
);
}
return mainQuery.execute(); return mainQuery.execute();
} }
@@ -560,28 +688,7 @@ export class OverviewService {
limit = 10, limit = 10,
timezone, timezone,
}: IGetTopGenericInput) { }: IGetTopGenericInput) {
const distinctSessionQuery = this.getDistinctSessions({ const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
projectId,
filters,
startDate,
endDate,
timezone,
});
const prefixColumn = (() => {
switch (column) {
case 'region':
return 'country';
case 'city':
return 'country';
case 'browser_version':
return 'browser';
case 'os_version':
return 'os';
}
return null;
})();
const offset = (cursor - 1) * limit; const offset = (cursor - 1) * limit;
const query = clix(this.client, timezone) const query = clix(this.client, timezone)
@@ -612,24 +719,15 @@ export class OverviewService {
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
let mainQuery = query; const mainQuery = this.withDistinctSessionsIfNeeded(query, {
projectId,
filters,
startDate,
endDate,
timezone,
});
if (this.isPageFilter(filters)) { return mainQuery.execute();
mainQuery = clix(this.client, timezone)
.with('distinct_sessions', distinctSessionQuery)
.merge(query)
.where(
'id',
'IN',
clix.exp('(SELECT session_id FROM distinct_sessions)'),
);
} else {
mainQuery.rawWhere(this.getRawWhereClause('sessions', filters));
}
const res = await mainQuery.execute();
return res;
} }
} }