From 19fcde2c9c16fc903bc2feee0c506dbc5a28aca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20D=27Eredit=C3=A0?= Date: Thu, 23 Jul 2026 17:39:03 +0200 Subject: [PATCH 1/5] refactor(queries): replace hardcoded event types with constants Replaced hardcoded `event_type` values with named constants from `EVENT_TYPE`, as in #3614. Added the `BOUNCE_THRESHOLD` constant and documented it in `docker-compose.yml`. --- docker-compose.yml | 1 + src/lib/constants.ts | 4 ++++ src/queries/sql/events/getWebsiteEvents.ts | 8 ++++---- src/queries/sql/sessions/getWebsiteSession.ts | 4 ++-- src/queries/sql/sessions/getWebsiteSessionStats.ts | 8 ++++---- src/queries/sql/sessions/getWebsiteSessions.ts | 10 +++++----- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 348c294ca..bc8d8da08 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: environment: DATABASE_URL: postgresql://umami:umami@db:5432/umami APP_SECRET: replace-me-with-a-random-string + # BOUNCE_THRESHOLD: 2 # OPTIONAL: set the minimum amount custom events to trigger a bounce depends_on: db: condition: service_healthy diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 89e250e73..b3bf04505 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,8 @@ export const CURRENT_VERSION = process.env.currentVersion; +export const BOUNCE_THRESHOLD = Math.max( + 1, + Number.parseInt(process.env.BOUNCE_THRESHOLD || '1', 10) || 1, +); export const AUTH_TOKEN = 'umami.auth'; export const LOCALE_CONFIG = 'umami.locale'; export const TIMEZONE_CONFIG = 'umami.timezone'; diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts index d7a8ecb63..d5f3376a5 100644 --- a/src/queries/sql/events/getWebsiteEvents.ts +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -22,8 +22,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { }); const searchQuery = search - ? `and ((event_name ilike {{search}} and event_type = 2) - or (url_path ilike {{search}} and event_type = 1))` + ? `and ((event_name ilike {{search}} and event_type = ${EVENT_TYPE.customEvent}) + or (url_path ilike {{search}} and event_type = ${EVENT_TYPE.pageView}))` : ''; return pagedRawQuery( @@ -77,8 +77,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { }); const searchQuery = search - ? `and ((positionCaseInsensitive(event_name, {search:String}) > 0 and event_type = 2) - or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = 1))` + ? `and ((positionCaseInsensitive(event_name, {search:String}) > 0 and event_type = ${EVENT_TYPE.customEvent}) + or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = ${EVENT_TYPE.pageView}))` : ''; return pagedRawQuery( diff --git a/src/queries/sql/sessions/getWebsiteSession.ts b/src/queries/sql/sessions/getWebsiteSession.ts index 3ad2c1677..8a4115157 100644 --- a/src/queries/sql/sessions/getWebsiteSession.ts +++ b/src/queries/sql/sessions/getWebsiteSession.ts @@ -49,8 +49,8 @@ async function relationalQuery(websiteId: string, sessionId: string) { session.city, min(website_event.created_at) as min_time, max(website_event.created_at) as max_time, - sum(case when website_event.event_type = 1 then 1 else 0 end) as views, - sum(case when website_event.event_type = 2 then 1 else 0 end) as events + sum(case when website_event.event_type = ${EVENT_TYPE.pageView} then 1 else 0 end) as views, + sum(case when website_event.event_type = ${EVENT_TYPE.customEvent} then 1 else 0 end) as events from session join website_event on website_event.session_id = session.session_id where session.website_id = {{websiteId::uuid}} diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts index a14fbc5f3..316288eaf 100644 --- a/src/queries/sql/sessions/getWebsiteSessionStats.ts +++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts @@ -36,11 +36,11 @@ async function relationalQuery( return rawQuery( ` select - sum(case when website_event.event_type = 1 then 1 else 0 end) as "pageviews", + sum(case when website_event.event_type = ${EVENT_TYPE.pageView} then 1 else 0 end) as "pageviews", count(distinct website_event.session_id) as "visitors", count(distinct website_event.visit_id) as "visits", count(distinct session.country) as "countries", - sum(case when website_event.event_type = 2 then 1 else 0 end) as "events" + sum(case when website_event.event_type = ${EVENT_TYPE.customEvent} then 1 else 0 end) as "events" from website_event ${cohortQuery} join session on website_event.session_id = session.session_id @@ -67,11 +67,11 @@ async function clickhouseQuery( if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { sql = ` select - sumIf(1, event_type = 1) as "pageviews", + sumIf(1, event_type = ${EVENT_TYPE.pageView}) as "pageviews", uniq(session_id) as "visitors", uniq(visit_id) as "visits", uniq(country) as "countries", - sumIf(1, event_type = 2) as "events" + sumIf(1, event_type = ${EVENT_TYPE.customEvent}) as "events" from website_event ${cohortQuery} where website_id = {websiteId:UUID} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts index 6e41a47c3..641099469 100644 --- a/src/queries/sql/sessions/getWebsiteSessions.ts +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -50,8 +50,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { min(website_event.created_at) as "firstAt", max(website_event.created_at) as "lastAt", count(distinct website_event.visit_id) as "visits", - sum(case when website_event.event_type = 1 then 1 else 0 end) as "views", - sum(case when website_event.event_type = 2 then 1 else 0 end) as "events", + sum(case when website_event.event_type = ${EVENT_TYPE.pageView} then 1 else 0 end) as "views", + sum(case when website_event.event_type = ${EVENT_TYPE.customEvent} then 1 else 0 end) as "events", max(website_event.created_at) as "createdAt" from website_event ${cohortQuery} @@ -125,8 +125,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('min(created_at)')} as firstAt, ${getDateStringSQL('max(created_at)')} as lastAt, uniq(visit_id) as visits, - sumIf(1, event_type = 1) as views, - sumIf(1, event_type = 2) as events, + sumIf(1, event_type = ${EVENT_TYPE.pageView}) as views, + sumIf(1, event_type = ${EVENT_TYPE.customEvent}) as events, max(created_at) as createdAt from website_event ${cohortQuery} @@ -155,7 +155,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ${getDateStringSQL('min(min_time)')} as firstAt, ${getDateStringSQL('max(max_time)')} as lastAt, uniq(visit_id) as visits, - sumIf(views, event_type = 1) as views, + sumIf(views, event_type = ${EVENT_TYPE.pageView}) as views, sum(length(event_name)) as events, max(max_time) as createdAt from website_event_stats_hourly as website_event From cb035fdc1a787b86fb3cbf503f2b3b5215154047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20D=27Eredit=C3=A0?= Date: Thu, 23 Jul 2026 17:56:28 +0200 Subject: [PATCH 2/5] feat(queries): add `BOUNCE_THRESHOLD` and enhance bounce calculation logic Updated bounce calculations to account for visits with custom events (`EVENT_TYPE.customEvent`). A visit counts as a bounce only when its custom event count is below `BOUNCE_THRESHOLD`, so engaged single pageview visits are no longer misreported. Since #3614 was opened v3 moved `getInsights` into `getBreakdown` and copied the old formula into the channel, pageview and session breakdowns, so those are updated too. --- .../sql/events/getEventExpandedMetrics.ts | 29 ++++++++++--- src/queries/sql/getChannelExpandedMetrics.ts | 34 ++++++++++++--- src/queries/sql/getWebsiteStats.ts | 42 +++++++++++++++---- .../pageviews/getPageviewExpandedMetrics.ts | 29 ++++++++++--- src/queries/sql/reports/getBreakdown.ts | 31 ++++++++++---- .../sql/sessions/getSessionExpandedMetrics.ts | 29 ++++++++++--- 6 files changed, 157 insertions(+), 37 deletions(-) diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts index 86bda8509..0e4840a11 100644 --- a/src/queries/sql/events/getEventExpandedMetrics.ts +++ b/src/queries/sql/events/getEventExpandedMetrics.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { BOUNCE_THRESHOLD, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -54,7 +54,7 @@ async function relationalQuery( sum(t.c) as "pageviews", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(case when t.c = 1 and (t.events_count - t.c) < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" from ( select @@ -63,10 +63,19 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time" + max(website_event.created_at) as "max_time", + max(( + select count(*) + from website_event we2 + where we2.website_id = website_event.website_id + and we2.session_id = website_event.session_id + and we2.visit_id = website_event.visit_id + and we2.created_at between {{startDate}} and {{endDate}} + and we2.event_type = ${EVENT_TYPE.customEvent} + )) as "events_count" from website_event ${cohortQuery} - ${joinSessionQuery} + ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} @@ -104,7 +113,7 @@ async function clickhouseQuery( sum(t.c) as "pageviews", uniq(t.session_id) as "visitors", uniq(t.visit_id) as "visits", - sum(if(t.c = 1, 1, 0)) as "bounces", + sumIf(1, t.c = 1 and (toInt64(ifNull(e.events_count, 0)) - toInt64(t.c)) < ${BOUNCE_THRESHOLD}) as "bounces", sum(max_time-min_time) as "totaltime" from ( select @@ -122,7 +131,15 @@ async function clickhouseQuery( ${filterQuery} group by name, session_id, visit_id ) as t - group by name + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id) + group by name order by visitors desc, visits desc limit ${limit} offset ${offset} diff --git a/src/queries/sql/getChannelExpandedMetrics.ts b/src/queries/sql/getChannelExpandedMetrics.ts index 6f225f9eb..6d9eee301 100644 --- a/src/queries/sql/getChannelExpandedMetrics.ts +++ b/src/queries/sql/getChannelExpandedMetrics.ts @@ -1,6 +1,8 @@ import clickhouse from '@/lib/clickhouse'; import { + BOUNCE_THRESHOLD, EMAIL_DOMAINS, + EVENT_TYPE, LLM_DOMAINS, PAID_AD_PARAMS, SEARCH_DOMAINS, @@ -120,20 +122,34 @@ async function relationalQuery( min(created_at) as min_time, max(created_at) as max_time from prefix + group by session_id, visit_id), + + visit_events as ( + select + session_id, + visit_id, + count(*) as events_count + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and event_type = ${EVENT_TYPE.customEvent} group by session_id, visit_id) - + select visit_channels.name, sum(visit_stats.c) as "pageviews", count(distinct visit_stats.session_id) as "visitors", count(distinct visit_stats.visit_id) as "visits", - sum(case when visit_stats.c = 1 then 1 else 0 end) as "bounces", + sum(case when visit_stats.c = 1 and coalesce(visit_events.events_count, 0) < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('visit_stats.min_time', 'visit_stats.max_time')}) as "totaltime" from visit_stats join visit_channels on visit_channels.session_id = visit_stats.session_id and visit_channels.visit_id = visit_stats.visit_id - group by visit_channels.name + left join visit_events + on visit_events.session_id = visit_stats.session_id + and visit_events.visit_id = visit_stats.visit_id + group by visit_channels.name order by visitors desc, visits desc `, queryParams, @@ -158,7 +174,7 @@ async function clickhouseQuery( sum(t.c) as "pageviews", uniq(t.session_id) as "visitors", uniq(t.visit_id) as "visits", - sum(if(t.c = 1, 1, 0)) as "bounces", + sumIf(1, t.c = 1 and ifNull(e.events_count, 0) < ${BOUNCE_THRESHOLD}) as "bounces", sum(max_time-min_time) as "totaltime" from ( select @@ -213,7 +229,15 @@ async function clickhouseQuery( ) group by session_id, visit_id ) as t - group by name + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id) + group by name order by visitors desc, visits desc; `, queryParams, diff --git a/src/queries/sql/getWebsiteStats.ts b/src/queries/sql/getWebsiteStats.ts index 527b9c152..0f74bc373 100644 --- a/src/queries/sql/getWebsiteStats.ts +++ b/src/queries/sql/getWebsiteStats.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { EVENT_COLUMNS } from '@/lib/constants'; +import { BOUNCE_THRESHOLD, EVENT_COLUMNS, EVENT_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -35,7 +35,9 @@ async function relationalQuery( }); const { excludeBounce } = filters; - const bounceQuery = excludeBounce ? '0' : 'coalesce(sum(case when t.c = 1 then 1 else 0 end), 0)'; + const bounceQuery = excludeBounce + ? '0' + : `coalesce(sum(case when t.c = 1 and t.events_count < ${BOUNCE_THRESHOLD} then 1 else 0 end), 0)`; return rawQuery( ` @@ -51,11 +53,20 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time" + max(website_event.created_at) as "max_time", + max(( + select count(*) + from website_event we2 + where we2.website_id = website_event.website_id + and we2.session_id = website_event.session_id + and we2.visit_id = website_event.visit_id + and we2.created_at between {{startDate}} and {{endDate}} + and we2.event_type = ${EVENT_TYPE.customEvent} + )) as "events_count" from website_event ${cohortQuery} ${excludeBounceQuery} - ${joinSessionQuery} + ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type NOT IN (2, 5) @@ -81,7 +92,9 @@ async function clickhouseQuery( let sql = ''; const { excludeBounce } = filters; - const bounceQuery = excludeBounce ? '0' : 'sumIf(1, t.c = 1)'; + const bounceQuery = excludeBounce + ? '0' + : `sumIf(1, t.c = 1 and ifNull(e.events_count, 0) < ${BOUNCE_THRESHOLD})`; if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { sql = ` @@ -106,7 +119,15 @@ async function clickhouseQuery( and event_type NOT IN (2, 5) ${filterQuery} group by session_id, visit_id - ) as t; + ) as t + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id); `; } else { sql = ` @@ -130,7 +151,14 @@ async function clickhouseQuery( and event_type NOT IN (2, 5) ${filterQuery} group by session_id, visit_id - ) as t; + ) as t + left join ( + select session_id, visit_id, toUInt32(sumIf(views, event_type = ${EVENT_TYPE.customEvent})) as events_count + from website_event_stats_hourly + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by session_id, visit_id + ) as e using (session_id, visit_id); `; } diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts index e25a0242c..153086584 100644 --- a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts +++ b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { FILTER_COLUMNS, GROUPED_DOMAINS, SESSION_COLUMNS } from '@/lib/constants'; +import { BOUNCE_THRESHOLD, EVENT_TYPE, FILTER_COLUMNS, GROUPED_DOMAINS, SESSION_COLUMNS } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -96,7 +96,7 @@ async function relationalQuery( sum(t.c) as "pageviews", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(case when t.c = 1 and t.events_count < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" from ( select @@ -105,12 +105,21 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time" + max(website_event.created_at) as "max_time", + max(( + select count(*) + from website_event we2 + where we2.website_id = website_event.website_id + and we2.session_id = website_event.session_id + and we2.visit_id = website_event.visit_id + and we2.created_at between {{startDate}} and {{endDate}} + and we2.event_type = ${EVENT_TYPE.customEvent} + )) as "events_count" from website_event ${cohortQuery} ${excludeBounceQuery} - ${joinSessionQuery} - ${entryExitQuery} + ${joinSessionQuery} + ${entryExitQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type NOT IN (2, 5) @@ -188,7 +197,7 @@ async function clickhouseQuery( sum(t.c) as "pageviews", uniq(t.session_id) as "visitors", uniq(t.visit_id) as "visits", - sum(if(t.c = 1, 1, 0)) as "bounces", + sumIf(1, t.c = 1 and ifNull(e.events_count, 0) < ${BOUNCE_THRESHOLD}) as "bounces", sum(max_time-min_time) as "totaltime" from ( select @@ -211,6 +220,14 @@ async function clickhouseQuery( ${filterQuery} group by name, session_id, visit_id ) as t + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id) group by name order by visitors desc, visits desc limit ${limit} diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts index 3d1e92a44..13dcccf17 100644 --- a/src/queries/sql/reports/getBreakdown.ts +++ b/src/queries/sql/reports/getBreakdown.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { BOUNCE_THRESHOLD, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -50,7 +50,7 @@ async function relationalQuery( sum(t.c) as "views", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(case when t.c = 1 and t.events_count < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime", ${parseFieldsByName(fields)} from ( @@ -60,14 +60,23 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time" + max(website_event.created_at) as "max_time", + max(( + select count(*) + from website_event we2 + where we2.website_id = website_event.website_id + and we2.session_id = website_event.session_id + and we2.visit_id = website_event.visit_id + and we2.created_at between {{startDate}} and {{endDate}} + and we2.event_type = ${EVENT_TYPE.customEvent} + )) as "events_count" from website_event - ${cohortQuery} + ${cohortQuery} ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} - group by ${parseFieldsByName(fields)}, + group by ${parseFieldsByName(fields)}, website_event.session_id, website_event.visit_id ) as t group by ${parseFieldsByName(fields)} @@ -99,7 +108,7 @@ async function clickhouseQuery( sum(t.c) as "views", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(if(t.c = 1, 1, 0)) as "bounces", + sumIf(1, t.c = 1 and ifNull(e.events_count, 0) < ${BOUNCE_THRESHOLD}) as "bounces", sum(max_time-min_time) as "totaltime", ${parseFieldsByName(fields)} from ( @@ -115,9 +124,17 @@ async function clickhouseQuery( where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} - group by ${parseFieldsByName(fields)}, + group by ${parseFieldsByName(fields)}, session_id, visit_id ) as t + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id) group by ${parseFieldsByName(fields)} order by 2 desc, 1 desc limit 500 diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts index f81acc769..c917a40f9 100644 --- a/src/queries/sql/sessions/getSessionExpandedMetrics.ts +++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { BOUNCE_THRESHOLD, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -62,7 +62,7 @@ async function relationalQuery( sum(t.c) as "pageviews", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(case when t.c = 1 and t.events_count < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" from ( select @@ -72,11 +72,20 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time" + max(website_event.created_at) as "max_time", + max(( + select count(*) + from website_event we2 + where we2.website_id = website_event.website_id + and we2.session_id = website_event.session_id + and we2.visit_id = website_event.visit_id + and we2.created_at between {{startDate}} and {{endDate}} + and we2.event_type = ${EVENT_TYPE.customEvent} + )) as "events_count" from website_event ${cohortQuery} ${excludeBounceQuery} - ${joinSessionQuery} + ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_type NOT IN (2, 5) @@ -122,7 +131,7 @@ async function clickhouseQuery( sum(t.c) as "pageviews", uniq(t.session_id) as "visitors", uniq(t.visit_id) as "visits", - sum(if(t.c = 1, 1, 0)) as "bounces", + sumIf(1, t.c = 1 and ifNull(e.events_count, 0) < ${BOUNCE_THRESHOLD}) as "bounces", sum(max_time-min_time) as "totaltime" from ( select @@ -144,7 +153,15 @@ async function clickhouseQuery( group by name, session_id, visit_id ${includeCountry ? ', country' : ''} ) as t - group by name + left join ( + select session_id, visit_id, toUInt32(count()) as events_count + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = ${EVENT_TYPE.customEvent} + group by session_id, visit_id + ) as e using (session_id, visit_id) + group by name ${includeCountry ? ', country' : ''} order by visitors desc, visits desc limit ${limit} From 82a5a122088c586aa413d13139844c8276c567d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20D=27Eredit=C3=A0?= Date: Sun, 26 Jul 2026 14:12:20 +0200 Subject: [PATCH 3/5] refactor(constants): simplify `BOUNCE_THRESHOLD` parsing `parseInt(undefined, 10)` returns NaN and the `|| 1` fallback already covers it, so the `|| '1'` default was redundant. --- src/lib/constants.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b3bf04505..5420bc6cc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,8 +1,5 @@ export const CURRENT_VERSION = process.env.currentVersion; -export const BOUNCE_THRESHOLD = Math.max( - 1, - Number.parseInt(process.env.BOUNCE_THRESHOLD || '1', 10) || 1, -); +export const BOUNCE_THRESHOLD = Math.max(1, Number.parseInt(process.env.BOUNCE_THRESHOLD, 10) || 1); export const AUTH_TOKEN = 'umami.auth'; export const LOCALE_CONFIG = 'umami.locale'; export const TIMEZONE_CONFIG = 'umami.timezone'; From 92e9b276d2a0b379aa43a7a2d78e4d74f8c3dce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20D=27Eredit=C3=A0?= Date: Sun, 26 Jul 2026 18:36:48 +0200 Subject: [PATCH 4/5] fix(queries): count custom events correctly on the hourly rollup, trying to address greptile findings `website_event_stats_hourly` sets `views` to `sumIf(1, event_type NOT IN (2, 5))`, so `views` is 0 on custom event rows and the `events_count` join was summing zeroes. Switched it to `sum(length(event_name))`, which is how `getWebsiteSessionStats` already counts custom events on that table. Also dropped the `getEventExpandedMetrics` change from the previous commit. That report is filtered to custom events, so every visit it lists has already fired at least one, and its `bounces` column counts visits where the event fired exactly once rather than visits with a single pageview. The threshold rule does not apply there, so the query is left untouched. --- .../sql/events/getEventExpandedMetrics.ts | 29 ++++--------------- src/queries/sql/getWebsiteStats.ts | 2 +- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts index 0e4840a11..86bda8509 100644 --- a/src/queries/sql/events/getEventExpandedMetrics.ts +++ b/src/queries/sql/events/getEventExpandedMetrics.ts @@ -1,5 +1,5 @@ import clickhouse from '@/lib/clickhouse'; -import { BOUNCE_THRESHOLD, EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -54,7 +54,7 @@ async function relationalQuery( sum(t.c) as "pageviews", count(distinct t.session_id) as "visitors", count(distinct t.visit_id) as "visits", - sum(case when t.c = 1 and (t.events_count - t.c) < ${BOUNCE_THRESHOLD} then 1 else 0 end) as "bounces", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" from ( select @@ -63,19 +63,10 @@ async function relationalQuery( website_event.visit_id, count(*) as "c", min(website_event.created_at) as "min_time", - max(website_event.created_at) as "max_time", - max(( - select count(*) - from website_event we2 - where we2.website_id = website_event.website_id - and we2.session_id = website_event.session_id - and we2.visit_id = website_event.visit_id - and we2.created_at between {{startDate}} and {{endDate}} - and we2.event_type = ${EVENT_TYPE.customEvent} - )) as "events_count" + max(website_event.created_at) as "max_time" from website_event ${cohortQuery} - ${joinSessionQuery} + ${joinSessionQuery} where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} ${filterQuery} @@ -113,7 +104,7 @@ async function clickhouseQuery( sum(t.c) as "pageviews", uniq(t.session_id) as "visitors", uniq(t.visit_id) as "visits", - sumIf(1, t.c = 1 and (toInt64(ifNull(e.events_count, 0)) - toInt64(t.c)) < ${BOUNCE_THRESHOLD}) as "bounces", + sum(if(t.c = 1, 1, 0)) as "bounces", sum(max_time-min_time) as "totaltime" from ( select @@ -131,15 +122,7 @@ async function clickhouseQuery( ${filterQuery} group by name, session_id, visit_id ) as t - left join ( - select session_id, visit_id, toUInt32(count()) as events_count - from website_event - where website_id = {websiteId:UUID} - and created_at between {startDate:DateTime64} and {endDate:DateTime64} - and event_type = ${EVENT_TYPE.customEvent} - group by session_id, visit_id - ) as e using (session_id, visit_id) - group by name + group by name order by visitors desc, visits desc limit ${limit} offset ${offset} diff --git a/src/queries/sql/getWebsiteStats.ts b/src/queries/sql/getWebsiteStats.ts index 0f74bc373..6ca085db4 100644 --- a/src/queries/sql/getWebsiteStats.ts +++ b/src/queries/sql/getWebsiteStats.ts @@ -153,7 +153,7 @@ async function clickhouseQuery( group by session_id, visit_id ) as t left join ( - select session_id, visit_id, toUInt32(sumIf(views, event_type = ${EVENT_TYPE.customEvent})) as events_count + select session_id, visit_id, toUInt32(sum(length(event_name))) as events_count from website_event_stats_hourly where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} From c9122c0becbe14db779e9d79000f9733c4ccc63d Mon Sep 17 00:00:00 2001 From: Sushant Kataria <3662310+sushant-kataria@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:43:09 -0400 Subject: [PATCH 5/5] fix(dashboard): use Edit label to match empty-state copy Empty dashboard message tells users to click Edit, but the header button was hardcoded as Design. Use t(labels.edit) so the control matches the i18n empty-state string. Fixes #4398 --- src/app/(main)/dashboard/DashboardViewHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(main)/dashboard/DashboardViewHeader.tsx b/src/app/(main)/dashboard/DashboardViewHeader.tsx index e4328fce8..f4594ef39 100644 --- a/src/app/(main)/dashboard/DashboardViewHeader.tsx +++ b/src/app/(main)/dashboard/DashboardViewHeader.tsx @@ -11,7 +11,7 @@ export function DashboardViewHeader() { return ( - }>Design + }>{t(labels.edit)} );