From dd36e97004aa335b8f754243e95169fc9ef588d7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 22 Aug 2026 01:14:05 +0200 Subject: [PATCH] feat(analytics): Events card + eventName filter on Web Analytics Custom `track()` events were stored (session_events Type='custom', web_events Kind='custom') but nothing on the analytics page read them. - webAnalyticsEventsQuery: top event names by firings + distinct sessions, rollup/raw pair with the usual web_events fallback; own filter excluded - `eventName` page filter: one SessionId semi-join against the custom rows, applied to every query on the page (KPIs, chart, pages, 12 breakdowns) - Events card (5th in the grid), sidebar Event section, URL param + chip - builder fixtures + regenerated SQL baseline, parity e2e cases --- .../src/routes/internal/query-engine.http.ts | 19 + ...eb-analytics-parity.clickhouse.e2e.test.ts | 8 + apps/web/src/api/warehouse/web-analytics.ts | 41 +- .../analytics/analytics-breakdown-panel.tsx | 18 +- .../analytics/analytics-filter-sidebar.tsx | 21 +- apps/web/src/components/analytics/filters.ts | 5 + .../services/atoms/warehouse-query-atoms.ts | 7 +- apps/web/src/routes/analytics/index.tsx | 41 +- packages/domain/src/http/query-engine.ts | 30 ++ .../src/__sql_baseline__/catalog.sql | 356 +++++++++++++++++- .../query-engine/src/ch/builder-fixtures.ts | 20 + packages/query-engine/src/ch/index.ts | 1 + .../src/ch/queries/web-analytics.ts | 168 ++++++++- packages/query-engine/src/registry/queries.ts | 20 + 14 files changed, 739 insertions(+), 16 deletions(-) diff --git a/apps/api/src/routes/internal/query-engine.http.ts b/apps/api/src/routes/internal/query-engine.http.ts index 8382aa2b2..db068cda4 100644 --- a/apps/api/src/routes/internal/query-engine.http.ts +++ b/apps/api/src/routes/internal/query-engine.http.ts @@ -61,6 +61,7 @@ import { WebAnalyticsTimeseriesResponse, WebAnalyticsPageviewsResponse, WebAnalyticsPagesResponse, + WebAnalyticsEventsResponse, WebAnalyticsBreakdownsResponse, CommitSha, FingerprintHash, @@ -1695,6 +1696,24 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleInternalApi, "query }) }), ) + .handle("webAnalyticsEvents", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* withWebEventsFallback( + (t, pl) => runQuery(Queries.webAnalyticsEvents, t, pl), + (t, pl) => runQuery(Queries.webAnalyticsEventsRaw, t, pl), + tenant, + payload, + ) + return new WebAnalyticsEventsResponse({ + data: rows.map((row) => ({ + name: String(row.name), + events: Number(row.events) || 0, + sessions: Number(row.sessions) || 0, + })), + }) + }), + ) .handle("webAnalyticsBreakdowns", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts b/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts index 9cb556bed..066aab638 100644 --- a/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts +++ b/apps/api/src/services/warehouse/web-analytics-parity.clickhouse.e2e.test.ts @@ -285,6 +285,9 @@ const FILTER_CASES: ReadonlyArray<{ readonly label: string; readonly filters: CH // narrow the page-view source through a subquery. { label: "replays-dimension", filters: { country: "DE" } }, { label: "replays-dimension+path", filters: { country: "DE", pagePath: "/pricing" } }, + // The event semi-join, alone and composed with a page filter. + { label: "event", filters: { eventName: "signup_started" } }, + { label: "event+path", filters: { eventName: "signup_started", pagePath: "/pricing" } }, { label: "all-dimensions", filters: { @@ -298,6 +301,7 @@ const FILTER_CASES: ReadonlyArray<{ readonly label: string; readonly filters: CH language: "en-US", utmSource: "twitter", visitorType: "new", + eventName: "signup_started", }, }, ] @@ -325,6 +329,10 @@ const QUERIES: ReadonlyArray<{ name: "webAnalyticsPages", compile: (filters) => CH.compile(CH.webAnalyticsPagesQuery({ ...filters, limit: 100 }), window).sql, }, + { + name: "webAnalyticsEvents", + compile: (filters) => CH.compile(CH.webAnalyticsEventsQuery({ ...filters, limit: 100 }), window).sql, + }, { name: "webAnalyticsBreakdowns", compile: (filters) => diff --git a/apps/web/src/api/warehouse/web-analytics.ts b/apps/web/src/api/warehouse/web-analytics.ts index 3f66d9604..b1787e608 100644 --- a/apps/web/src/api/warehouse/web-analytics.ts +++ b/apps/web/src/api/warehouse/web-analytics.ts @@ -1,4 +1,4 @@ -// One filter schema shared by all five queries, so the /analytics route builds a +// One filter schema shared by all six queries, so the /analytics route builds a // single filter object and every panel narrows identically. See // packages/query-engine/src/ch/queries/web-analytics.ts for why the page reads // two tables and what each half covers. @@ -6,6 +6,7 @@ import { Effect, Schema } from "effect" import { WebAnalyticsBreakdownsRequest, + WebAnalyticsEventsRequest, WebAnalyticsPagesRequest, WebAnalyticsPageviewsRequest, WebAnalyticsSummaryRequest, @@ -27,6 +28,7 @@ const WebAnalyticsFilterFields = { utmMedium: Schema.optional(Schema.String), utmCampaign: Schema.optional(Schema.String), visitorType: Schema.optional(Schema.Literals(["new", "returning"])), + eventName: Schema.optional(Schema.String), } as const const TimeWindowFields = { @@ -53,6 +55,12 @@ const WebAnalyticsPagesInputSchema = Schema.Struct({ limit: Schema.optional(PositiveInt), }) +const WebAnalyticsEventsInputSchema = Schema.Struct({ + ...TimeWindowFields, + ...WebAnalyticsFilterFields, + limit: Schema.optional(PositiveInt), +}) + const WebAnalyticsBreakdownsInputSchema = Schema.Struct({ ...TimeWindowFields, ...WebAnalyticsFilterFields, @@ -62,6 +70,7 @@ const WebAnalyticsBreakdownsInputSchema = Schema.Struct({ export type GetWebAnalyticsSummaryInput = (typeof WebAnalyticsSummaryInputSchema)["Encoded"] export type GetWebAnalyticsTimeseriesInput = (typeof WebAnalyticsTimeseriesInputSchema)["Encoded"] export type GetWebAnalyticsPagesInput = (typeof WebAnalyticsPagesInputSchema)["Encoded"] +export type GetWebAnalyticsEventsInput = (typeof WebAnalyticsEventsInputSchema)["Encoded"] export type GetWebAnalyticsBreakdownsInput = (typeof WebAnalyticsBreakdownsInputSchema)["Encoded"] export interface WebAnalyticsSummary { @@ -115,6 +124,13 @@ export interface WebAnalyticsPage { sessions: number } +/** One `track()` event name, with firings and the distinct sessions that fired it. */ +export interface WebAnalyticsEvent { + name: string + events: number + sessions: number +} + export interface WebAnalyticsFacetRow { name: string count: number @@ -235,6 +251,29 @@ const getWebAnalyticsPagesEffect = Effect.fn("QueryEngine.getWebAnalyticsPages") return { data: result.data satisfies ReadonlyArray } }) +export function getWebAnalyticsEvents({ data }: { data: GetWebAnalyticsEventsInput }) { + return getWebAnalyticsEventsEffect({ data }) +} + +const getWebAnalyticsEventsEffect = Effect.fn("QueryEngine.getWebAnalyticsEvents")(function* ({ + data, +}: { + data: GetWebAnalyticsEventsInput +}) { + const input = yield* decodeInput(WebAnalyticsEventsInputSchema, data, "getWebAnalyticsEvents") + + const result = yield* runWarehouseQuery("webAnalyticsEvents", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.queryEngine.webAnalyticsEvents({ + payload: new WebAnalyticsEventsRequest(input), + }) + }), + ) + + return { data: result.data satisfies ReadonlyArray } +}) + export function getWebAnalyticsBreakdowns({ data }: { data: GetWebAnalyticsBreakdownsInput }) { return getWebAnalyticsBreakdownsEffect({ data }) } diff --git a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx index 8c36a9fa2..a1a15d14d 100644 --- a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx +++ b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx @@ -65,6 +65,11 @@ export interface BreakdownDimension { * icon follows from the name keep using `analyticsRowIcon`. */ readonly renderIcon?: (row: BreakdownRow) => ReactNode + /** + * Column head for `views`, when the dimension carries it. Defaults to "Views"; + * the Events dimension ranks by firings and says so. + */ + readonly viewsLabel?: string } type SortKey = "name" | "count" | "views" @@ -152,6 +157,7 @@ export function AnalyticsBreakdownPanel({ // have them, sessions otherwise — so the bar always tracks the number the // rows are actually ordered by. const hasViews = dimension.rows.some((row) => row.views !== undefined) + const viewsLabel = dimension.viewsLabel ?? "Views" const rows = useMemo(() => { const total = dimension.rows.reduce((sum, row) => sum + (hasViews ? (row.views ?? 0) : row.count), 0) @@ -188,6 +194,7 @@ export function AnalyticsBreakdownPanel({ rows: sorted, label, hasViews, + viewsLabel, hasIcons, selected, waiting, @@ -257,8 +264,11 @@ export function AnalyticsBreakdownPanel({
{dimension.tab} - Ranked by {hasViews ? "page views" : "sessions"} over the selected window. - Pick a row to filter the page. + Ranked by{" "} + {hasViews + ? (dimension.viewsLabel?.toLowerCase() ?? "page views") + : "sessions"}{" "} + over the selected window. Pick a row to filter the page.
@@ -357,6 +367,7 @@ interface BreakdownTableProps { rows: ReadonlyArray label: (name: string) => string hasViews: boolean + viewsLabel: string hasIcons: boolean selected: string | undefined waiting?: boolean @@ -380,6 +391,7 @@ function BreakdownTable({ rows, label, hasViews, + viewsLabel, hasIcons, selected, waiting, @@ -420,7 +432,7 @@ function BreakdownTable({ /> {hasViews ? ( - label="Views" + label={viewsLabel} width={ranked ? "w-16 sm:w-24" : "w-16"} align="right" sortKey="views" diff --git a/apps/web/src/components/analytics/analytics-filter-sidebar.tsx b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx index af412fd83..66a0fce63 100644 --- a/apps/web/src/components/analytics/analytics-filter-sidebar.tsx +++ b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx @@ -3,7 +3,7 @@ import { Result } from "@/lib/effect-atom" import { Separator } from "@maple/ui/components/ui/separator" import { cn } from "@maple/ui/lib/utils" -import type { WebAnalyticsBreakdowns } from "@/api/warehouse/web-analytics" +import type { WebAnalyticsBreakdowns, WebAnalyticsEvent } from "@/api/warehouse/web-analytics" import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" import { FilterSection, @@ -32,6 +32,7 @@ import { browserIconFor, deviceIconFor } from "@/components/replays/session-icon interface AnalyticsFilterSidebarProps { breakdownsResult: Result.Result + eventsResult: Result.Result<{ data: ReadonlyArray }, QueryAtomFailure> filters: AnalyticsFilters onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void onClearFilters: () => void @@ -60,6 +61,7 @@ const utmSourceIcon = (name: string) => (isHostLike(name) ? hostIcon(name) : nul export function AnalyticsFilterSidebar({ breakdownsResult, + eventsResult, filters, onFilterChange, onClearFilters, @@ -70,6 +72,11 @@ export function AnalyticsFilterSidebar({ .onSuccess((breakdowns, result) => ( rows.data) + .orElse(() => [])} waiting={result.waiting} filters={filters} onFilterChange={onFilterChange} @@ -81,12 +88,14 @@ export function AnalyticsFilterSidebar({ function AnalyticsFilterSidebarView({ breakdowns, + events, waiting, filters, onFilterChange, onClearFilters, }: { breakdowns: WebAnalyticsBreakdowns + events: ReadonlyArray waiting: boolean filters: AnalyticsFilters onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void @@ -146,6 +155,16 @@ function AnalyticsFilterSidebarView({ options={toOptions(breakdowns.entryPaths)} {...single("pagePath")} /> + {/* Counts are sessions that fired the event, matching every other count + in this rail; firings live on the card. Hidden outright when nothing + is tracked — an empty "Event" section would read as a broken filter. */} + {events.length > 0 || filters.eventName ? ( + ({ name: event.name, count: event.sessions }))} + {...single("eventName")} + /> + ) : null} = { utmMedium: "utm_medium", utmCampaign: "utm_campaign", visitorType: "visitor", + eventName: "event", } satisfies Record /** Filter key → sidebar section heading. Sentence case, matching the rest of the app. */ @@ -72,6 +76,7 @@ export const FILTER_SECTION_LABEL: Record = { utmMedium: "UTM medium", utmCampaign: "UTM campaign", visitorType: "Visitor", + eventName: "Event", } satisfies Record const FILTER_KEYS = Object.keys(FILTER_CHIP_LABEL) as ReadonlyArray diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index d7cfaaf03..4813ba458 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -107,6 +107,7 @@ import { import { getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" import { getWebAnalyticsBreakdowns, + getWebAnalyticsEvents, getWebAnalyticsPages, getWebAnalyticsPageviews, getWebAnalyticsSummary, @@ -253,7 +254,7 @@ export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) -// Web analytics — one page, five atoms, all 30s. Traffic numbers are watched +// Web analytics — one page, six atoms, all 30s. Traffic numbers are watched // during a launch, so a longer TTL reads as a stalled page; a shorter one just // re-runs the same 30-day-TTL scans. export const webAnalyticsSummaryResultAtom = makeQueryAtomFamily(getWebAnalyticsSummary, { @@ -272,6 +273,10 @@ export const webAnalyticsPagesResultAtom = makeQueryAtomFamily(getWebAnalyticsPa staleTime: 30_000, }) +export const webAnalyticsEventsResultAtom = makeQueryAtomFamily(getWebAnalyticsEvents, { + staleTime: 30_000, +}) + export const webAnalyticsBreakdownsResultAtom = makeQueryAtomFamily(getWebAnalyticsBreakdowns, { staleTime: 30_000, }) diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx index 4fa6f897c..aeebab863 100644 --- a/apps/web/src/routes/analytics/index.tsx +++ b/apps/web/src/routes/analytics/index.tsx @@ -12,7 +12,7 @@ import { QueryErrorState } from "@/components/common/query-error-state" import { PageHero } from "@/components/infra/primitives/page-hero" import { PlayRotateClockwiseIcon } from "@/components/icons" import { chartBucketSeconds } from "@/components/infra/chart-utils" -import type { WebAnalyticsBreakdowns } from "@/api/warehouse/web-analytics" +import type { WebAnalyticsBreakdowns, WebAnalyticsEvent } from "@/api/warehouse/web-analytics" import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" import { AnalyticsBreakdownPanel, @@ -44,6 +44,7 @@ import { } from "@/components/analytics/filters" import { webAnalyticsBreakdownsResultAtom, + webAnalyticsEventsResultAtom, webAnalyticsPagesResultAtom, webAnalyticsPageviewsResultAtom, webAnalyticsSummaryResultAtom, @@ -62,6 +63,7 @@ const analyticsSearchSchema = Schema.Struct({ const DEFAULT_PRESET = "7d" const PAGES_LIMIT = 100 +const EVENTS_LIMIT = 100 const BREAKDOWN_LIMIT = 50 export const Route = createFileRoute("/analytics/")({ @@ -119,6 +121,15 @@ function WebAnalyticsPage() { }), ) + // Its own query, not a branch of the breakdowns union: custom events live on + // the page-view source, not `session_replays`. Read here rather than in the + // content so the sidebar's Event section and the Events card share one fetch. + const eventsResult = useRetainedRefreshableResultValue( + webAnalyticsEventsResultAtom({ + data: { startTime, endTime, limit: EVENTS_LIMIT, ...filters }, + }), + ) + const chips = activeFilterChips(filters) return ( @@ -129,6 +140,7 @@ function WebAnalyticsPage() {
@@ -256,12 +269,14 @@ function AnalyticsContent({ endTime, filters, breakdownsResult, + eventsResult, onToggleFilter, }: { startTime: string endTime: string filters: AnalyticsFilters breakdownsResult: Result.Result + eventsResult: Result.Result<{ data: ReadonlyArray }, QueryAtomFailure> onToggleFilter: (key: AnalyticsFilterKey, value: string) => void }) { const bucketSeconds = chartBucketSeconds(startTime, endTime) @@ -509,6 +524,29 @@ function AnalyticsContent({ }, ] + const events = Result.builder(eventsResult) + .onSuccess((rows) => rows.data) + .orElse(() => []) + + const eventDimensions: ReadonlyArray = [ + { + tab: "Events", + // Ranked by firings, with the sessions that fired each beside it — + // the same two-column shape as Pages, read from the same source. + rows: events.map((event) => ({ + name: event.name, + count: event.sessions, + views: event.events, + })), + filterKey: "eventName", + noun: "event", + nounPlural: "events", + viewsLabel: "Events", + emptyMessage: + 'No custom events in the selected window. Send one with track("name", props) from the browser SDK.', + }, + ] + // `items-start` so each card sizes to its own content instead of // stretching to match the tallest in its row — a Devices card with // three rows should not be as tall as a Pages card with fifty. @@ -521,6 +559,7 @@ function AnalyticsContent({ { id: "content", dimensions: pageDimensions }, { id: "technology", dimensions: devices }, { id: "audience", dimensions: geography }, + { id: "events", dimensions: eventDimensions }, ] return ( diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 6db630cb8..2a2dc93fd 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -1181,6 +1181,8 @@ const WebAnalyticsFilterFields = { utmMedium: Schema.optional(Schema.String), utmCampaign: Schema.optional(Schema.String), visitorType: Schema.optional(Schema.Literals(["new", "returning"])), + // Sessions in which a `track(eventName)` call fired. + eventName: Schema.optional(Schema.String), } as const export class WebAnalyticsSummaryRequest extends Schema.Class( @@ -1278,6 +1280,27 @@ export class WebAnalyticsPagesResponse extends Schema.Class( + "WebAnalyticsEventsRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + limit: Schema.optional(Schema.Number), + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsEventsResponse extends Schema.Class( + "WebAnalyticsEventsResponse", +)({ + data: Schema.Array( + Schema.Struct({ + name: Schema.String, + events: Schema.Number, + sessions: Schema.Number, + }), + ), +}) {} + export class WebAnalyticsBreakdownsRequest extends Schema.Class( "WebAnalyticsBreakdownsRequest", )({ @@ -2155,6 +2178,13 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("webAnalyticsEvents", "/web-analytics-events", { + payload: WebAnalyticsEventsRequest, + success: WebAnalyticsEventsResponse, + error: queryEngineEndpointErrors, + }), + ) .add( HttpApiEndpoint.post("webAnalyticsBreakdowns", "/web-analytics-breakdowns", { payload: WebAnalyticsBreakdownsRequest, diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 498aa6a70..a00c71644 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -1979,7 +1979,7 @@ SELECT LIMIT 2 FORMAT JSON --- builder:web-analytics:webAnalyticsBreakdownsQuery:all-dimensions-filtered [81a04a1d] +-- builder:web-analytics:webAnalyticsBreakdownsQuery:all-dimensions-filtered [dd21e64d] SELECT if(ReferrerHost = '', '(none)', ReferrerHost) AS name, uniq(SessionId) AS count, @@ -2007,6 +2007,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2038,6 +2047,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND Country != '' GROUP BY name ORDER BY count DESC @@ -2070,6 +2088,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND DeviceType != '' GROUP BY name ORDER BY count DESC @@ -2102,6 +2129,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND BrowserName != '' GROUP BY name ORDER BY count DESC @@ -2134,6 +2170,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND OsName != '' GROUP BY name ORDER BY count DESC @@ -2166,6 +2211,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND Language != '' GROUP BY name ORDER BY count DESC @@ -2198,6 +2252,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2229,6 +2292,15 @@ SELECT AND UtmSource = 'twitter' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2260,6 +2332,15 @@ SELECT AND UtmSource = 'twitter' AND UtmMedium = 'social' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2291,6 +2372,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND EntryPath != '' GROUP BY name ORDER BY count DESC @@ -2323,6 +2413,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND ExitPath != '' GROUP BY name ORDER BY count DESC @@ -2355,13 +2454,22 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) AND Host != '' GROUP BY name ORDER BY count DESC LIMIT 50 FORMAT JSON --- builder:web-analytics:webAnalyticsBreakdownsQuery:all-dimensions-filtered-rollup [fd0344cd] +-- builder:web-analytics:webAnalyticsBreakdownsQuery:all-dimensions-filtered-rollup [99fac2d3] SELECT if(ReferrerHost = '', '(none)', ReferrerHost) AS name, uniq(SessionId) AS count, @@ -2389,6 +2497,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2420,6 +2537,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND Country != '' GROUP BY name ORDER BY count DESC @@ -2452,6 +2578,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND DeviceType != '' GROUP BY name ORDER BY count DESC @@ -2484,6 +2619,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND BrowserName != '' GROUP BY name ORDER BY count DESC @@ -2516,6 +2660,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND OsName != '' GROUP BY name ORDER BY count DESC @@ -2548,6 +2701,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND Language != '' GROUP BY name ORDER BY count DESC @@ -2580,6 +2742,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2611,6 +2782,15 @@ SELECT AND UtmSource = 'twitter' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2642,6 +2822,15 @@ SELECT AND UtmSource = 'twitter' AND UtmMedium = 'social' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) GROUP BY name ORDER BY count DESC LIMIT 50 @@ -2673,6 +2862,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND EntryPath != '' GROUP BY name ORDER BY count DESC @@ -2705,6 +2903,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND ExitPath != '' GROUP BY name ORDER BY count DESC @@ -2737,6 +2944,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) AND Host != '' GROUP BY name ORDER BY count DESC @@ -3051,6 +3267,120 @@ SELECT LIMIT 50 FORMAT JSON +-- builder:web-analytics:webAnalyticsEventsQuery:default [7a2203c7] +SELECT + Message AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsEventsQuery:default-rollup [d3ea0b13] +SELECT + EventName AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsEventsQuery:semi-joined [9f5b9302] +SELECT + Message AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_replays + WHERE OrgId = 'org_sql_catalog' + AND StartTime >= '2026-01-01 10:30:00' + AND StartTime <= '2026-01-03 14:15:00' + AND Country = 'DE' + GROUP BY sessionId) + AND Message != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsEventsQuery:semi-joined-rollup [ebdeba2a] +SELECT + EventName AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_replays + WHERE OrgId = 'org_sql_catalog' + AND StartTime >= '2026-01-01 10:30:00' + AND StartTime <= '2026-01-03 14:15:00' + AND Country = 'DE' + GROUP BY sessionId) + AND EventName != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsEventsQuery:url-filtered [8b46502c] +SELECT + Message AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND domain(Url) = 'maple.dev' + AND Message != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsEventsQuery:url-filtered-rollup [18968e6e] +SELECT + EventName AS name, + count() AS events, + uniq(SessionId) AS sessions + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND Host = 'maple.dev' + AND EventName != '' + GROUP BY name + ORDER BY events DESC + LIMIT 100 + FORMAT JSON + -- builder:web-analytics:webAnalyticsPagesQuery:default [ee65a1f8] SELECT domain(Url) AS host, @@ -3273,7 +3603,7 @@ SELECT AND StartTime <= '2026-01-03 14:15:00' FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:filtered [5b45919d] +-- builder:web-analytics:webAnalyticsSummaryQuery:filtered [5a9fc283] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, @@ -3305,9 +3635,18 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM session_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Type = 'custom' + AND Message = 'signup_started' + GROUP BY sessionId) FORMAT JSON --- builder:web-analytics:webAnalyticsSummaryQuery:filtered-rollup [3746231e] +-- builder:web-analytics:webAnalyticsSummaryQuery:filtered-rollup [b2b3770a] SELECT uniqIf(VisitorId, VisitorId != '') AS visitors, uniq(SessionId) AS sessions, @@ -3339,6 +3678,15 @@ SELECT AND UtmMedium = 'social' AND UtmCampaign = 'launch' AND VisitorIsNew = 1 + AND SessionId IN (SELECT + SessionId AS sessionId + FROM web_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' + AND Kind = 'custom' + AND EventName = 'signup_started' + GROUP BY sessionId) FORMAT JSON -- builder:web-analytics:webAnalyticsTimeseriesQuery:default [bdbaa144] diff --git a/packages/query-engine/src/ch/builder-fixtures.ts b/packages/query-engine/src/ch/builder-fixtures.ts index 11804ba94..c86d5819c 100644 --- a/packages/query-engine/src/ch/builder-fixtures.ts +++ b/packages/query-engine/src/ch/builder-fixtures.ts @@ -79,6 +79,7 @@ const WEB_ANALYTICS_ALL_FILTERS = { utmMedium: "social", utmCampaign: "launch", visitorType: "new", + eventName: "signup_started", } as const const webAnalyticsVariants = ( @@ -127,6 +128,25 @@ const webAnalyticsFixtures: ReadonlyArray = [ ...webAnalyticsVariants("webAnalyticsPagesQuery", "semi-joined", (useWebEvents) => CH.compile(CH.webAnalyticsPagesQuery({ limit: 100, country: "DE", useWebEvents }), window), ), + ...webAnalyticsVariants("webAnalyticsEventsQuery", "default", (useWebEvents) => + CH.compile(CH.webAnalyticsEventsQuery({ limit: 100, useWebEvents }), window), + ), + ...webAnalyticsVariants("webAnalyticsEventsQuery", "url-filtered", (useWebEvents) => + CH.compile(CH.webAnalyticsEventsQuery({ limit: 100, host: "maple.dev", useWebEvents }), window), + ), + // `eventName` alongside a replays dimension: the replays semi-join must appear + // and the event's own filter must NOT — this is the breakdown it is picked from. + ...webAnalyticsVariants("webAnalyticsEventsQuery", "semi-joined", (useWebEvents) => + CH.compile( + CH.webAnalyticsEventsQuery({ + limit: 100, + country: "DE", + eventName: "signup_started", + useWebEvents, + }), + window, + ), + ), ...webAnalyticsVariants("webAnalyticsBreakdownsQuery", "default", (useWebEvents) => CH.compileUnion(CH.webAnalyticsBreakdownsQuery({ useWebEvents }), window), ), diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 522a0bc03..c66031b64 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -157,6 +157,7 @@ export { webAnalyticsTimeseriesQuery, webAnalyticsPageviewsTimeseriesQuery, webAnalyticsPagesQuery, + webAnalyticsEventsQuery, webAnalyticsBreakdownsQuery, type WebAnalyticsFilters, type WebAnalyticsFacetKey, diff --git a/packages/query-engine/src/ch/queries/web-analytics.ts b/packages/query-engine/src/ch/queries/web-analytics.ts index 5edb258b8..cc74ac045 100644 --- a/packages/query-engine/src/ch/queries/web-analytics.ts +++ b/packages/query-engine/src/ch/queries/web-analytics.ts @@ -25,6 +25,15 @@ import { WEB_ANALYTICS_UNSET } from "@maple/domain/query-engine" */ const NAVIGATION = "navigation" +/** + * The custom-event discriminator, the other value `web_events.Kind` takes. A + * `track(name)` call lands as `Type = 'custom'` with the name in `Message` on the + * raw table and as `Kind = 'custom'` / `EventName = name` on the rollup. + */ +const CUSTOM = "custom" + +type EventKind = typeof NAVIGATION | typeof CUSTOM + // assumeNotNull(x) — drops the Nullable wrapper for a caller whose WHERE (or, as // below, whose `-If` condition) already excludes the NULLs. Generic per call // site, so declared here rather than via defineFn — same as session-replays.ts. @@ -64,6 +73,12 @@ export interface WebAnalyticsFilters { readonly utmCampaign?: string /** `new` keeps first-ever sessions for a visitor, `returning` the rest. */ readonly visitorType?: "new" | "returning" + /** + * Sessions in which a `track(eventName)` call fired — a semi-join on + * `SessionId` against the custom rows of the page-view source, independent of + * `host` / `pagePath` (see {@link eventSessionsSubquery}). + */ + readonly eventName?: string /** * Read page views from the `web_events` rollup instead of `session_events`. * @@ -91,6 +106,7 @@ export type WebAnalyticsFacetKey = | "entryPath" | "exitPath" | "host" + | "eventName" type ReplaysAccessor = ColumnAccessor type EventsAccessor = ColumnAccessor @@ -108,12 +124,27 @@ function navigationConditionsRaw( $: EventsAccessor, filters: WebAnalyticsFilters, only?: "host" | "pagePath", +): Array { + return eventConditionsRaw($, filters, NAVIGATION, only) +} + +/** + * The predicate {@link navigationConditionsRaw} is the page-view case of: the + * same org / time / URL bounds over raw `session_events`, for either of the two + * event types product analytics reads. Custom events carry the `Url` of the page + * they fired on, so `host` / `pagePath` mean the same thing for both kinds. + */ +function eventConditionsRaw( + $: EventsAccessor, + filters: WebAnalyticsFilters, + kind: EventKind, + only?: "host" | "pagePath", ): Array { return [ $.OrgId.eq(param.string("orgId")), $.Timestamp.gte(param.dateTime("startTime")), $.Timestamp.lte(param.dateTime("endTime")), - $.Type.eq(NAVIGATION), + $.Type.eq(kind), only === "pagePath" ? undefined : CH.when(filters.host, (v: string) => CH.domain_($.Url).eq(v)), only === "host" ? undefined : CH.when(filters.pagePath, (v: string) => CH.path_($.Url).eq(v)), ] @@ -133,12 +164,22 @@ function navigationConditionsRollup( $: WebEventsAccessor, filters: WebAnalyticsFilters, only?: "host" | "pagePath", +): Array { + return eventConditionsRollup($, filters, NAVIGATION, only) +} + +/** The rollup counterpart of {@link eventConditionsRaw}. */ +function eventConditionsRollup( + $: WebEventsAccessor, + filters: WebAnalyticsFilters, + kind: EventKind, + only?: "host" | "pagePath", ): Array { return [ $.OrgId.eq(param.string("orgId")), $.Timestamp.gte(param.dateTime("startTime")), $.Timestamp.lte(param.dateTime("endTime")), - $.Kind.eq(NAVIGATION), + $.Kind.eq(kind), only === "pagePath" ? undefined : CH.when(filters.host, (v: string) => $.Host.eq(v)), only === "host" ? undefined : CH.when(filters.pagePath, (v: string) => $.PagePath.eq(v)), ] @@ -181,6 +222,46 @@ function navigationSessionsSubquery( .groupBy("sessionId") } +/** + * `SELECT SessionId FROM WHERE ` — + * which sessions fired the selected `track()` event. + * + * Deliberately independent of `host` / `pagePath`: "sessions that fired + * `signup_started`" and "sessions that viewed `/pricing`" compose as two + * semi-joins, so an event filter never silently narrows to events fired *on* + * the filtered page. The `EventName` skip index on the rollup is what makes the + * equality cheap for a rare event. + */ +function eventSessionsSubquery( + filters: WebAnalyticsFilters, + eventName: string, +): CHQuery { + return filters.useWebEvents + ? from(WebEvents) + .select(($) => ({ sessionId: $.SessionId })) + .where(($) => [...eventConditionsRollup($, {}, CUSTOM), $.EventName.eq(eventName)]) + .groupBy("sessionId") + : from(SessionEvents) + .select(($) => ({ sessionId: $.SessionId })) + .where(($) => [...eventConditionsRaw($, {}, CUSTOM), $.Message.eq(eventName)]) + .groupBy("sessionId") +} + +/** + * The `eventName` semi-join clause, appended to every query on the page — + * source-independent, like {@link replaysSemiJoin}. `exclude` lets the events + * breakdown drop its own filter so the alternatives stay listed. + */ +function eventSemiJoin( + sessionId: CH.Expr, + filters: WebAnalyticsFilters, + exclude?: WebAnalyticsFacetKey, +): CH.Condition | undefined { + return exclude !== "eventName" && filters.eventName !== undefined + ? inSubquery(sessionId, eventSessionsSubquery(filters, filters.eventName)) + : undefined +} + /** * Filter equality for the acquisition columns: `WEB_ANALYTICS_UNSET` means "the * column is empty" (the group the breakdown emits under that name), anything @@ -246,6 +327,7 @@ function replaysWhere( CH.when(filters.visitorType, (v: "new" | "returning") => v === "new" ? $.VisitorIsNew.eq(1) : $.VisitorIsNew.eq(0), ), + eventSemiJoin($.SessionId, filters, exclude), ] } @@ -275,11 +357,16 @@ function needsSessionSemiJoin(filters: WebAnalyticsFilters): boolean { * recursing into `navigationSessionsSubquery`: `session_events` filters both * directly off `Url`, and routing them through `session_replays` would silently * drop the sessions with no analytics block from the page-view numbers. + * `eventName` is left out for the same reason — the event source applies it + * directly via {@link eventSemiJoin}, so threading it through here would only + * nest the same semi-join twice. */ function matchingSessionsSubquery(filters: WebAnalyticsFilters) { return from(SessionReplays) .select(($) => ({ sessionId: $.SessionId })) - .where(($) => replaysWhere($, { ...filters, host: undefined, pagePath: undefined })) + .where(($) => + replaysWhere($, { ...filters, host: undefined, pagePath: undefined, eventName: undefined }), + ) .groupBy("sessionId") } @@ -300,7 +387,11 @@ function navigationWhereRaw( $: EventsAccessor, filters: WebAnalyticsFilters, ): Array { - return [...navigationConditionsRaw($, filters), replaysSemiJoin($.SessionId, filters)] + return [ + ...navigationConditionsRaw($, filters), + replaysSemiJoin($.SessionId, filters), + eventSemiJoin($.SessionId, filters), + ] } /** WHERE conditions for the page-view queries over the `web_events` rollup. */ @@ -308,7 +399,11 @@ function navigationWhereRollup( $: WebEventsAccessor, filters: WebAnalyticsFilters, ): Array { - return [...navigationConditionsRollup($, filters), replaysSemiJoin($.SessionId, filters)] + return [ + ...navigationConditionsRollup($, filters), + replaysSemiJoin($.SessionId, filters), + eventSemiJoin($.SessionId, filters), + ] } // Summary KPIs @@ -537,6 +632,69 @@ export function webAnalyticsPagesQuery( .format("JSON") } +// Top custom events + +export interface WebAnalyticsEventsOpts extends WebAnalyticsFilters { + readonly limit?: number +} + +export interface WebAnalyticsEventsOutput { + readonly name: string + readonly events: number + readonly sessions: number +} + +/** + * Most-fired `track()` events by name — `count()` of firings and the distinct + * sessions that fired each, over the custom rows of the page-view source. + * + * `host` / `pagePath` narrow to events fired *on* that page, the same reading as + * the Pages query; the `session_replays` dimensions reach it through the semi-join. + * Its own `eventName` filter is excluded — this is the breakdown that filter is + * picked from, and the alternatives have to stay listed beside the selection. + * + * A customer's `track('$pageview')` shows up here as an event called `$pageview` + * on purpose: `Kind` / `Type` is the discriminator, never the name. Empty names + * cannot reach the table (the SDK trims and drops them) but are guarded anyway so + * a malformed row can never render as a blank first line. + */ +export function webAnalyticsEventsQuery( + opts: WebAnalyticsEventsOpts = {}, +): CHQuery { + const limit = opts.limit ?? 100 + return opts.useWebEvents + ? from(WebEvents) + .select(($) => ({ + name: $.EventName, + events: CH.count(), + sessions: CH.uniq($.SessionId), + })) + .where(($) => [ + ...eventConditionsRollup($, opts, CUSTOM), + replaysSemiJoin($.SessionId, opts), + $.EventName.neq(""), + ]) + .groupBy("name") + .orderBy(["events", "desc"]) + .limit(limit) + .format("JSON") + : from(SessionEvents) + .select(($) => ({ + name: $.Message, + events: CH.count(), + sessions: CH.uniq($.SessionId), + })) + .where(($) => [ + ...eventConditionsRaw($, opts, CUSTOM), + replaysSemiJoin($.SessionId, opts), + $.Message.neq(""), + ]) + .groupBy("name") + .orderBy(["events", "desc"]) + .limit(limit) + .format("JSON") +} + // Dimension breakdowns (UNION ALL fan-out) export type WebAnalyticsBreakdownsOpts = WebAnalyticsFilters & { diff --git a/packages/query-engine/src/registry/queries.ts b/packages/query-engine/src/registry/queries.ts index f77533d1f..8fe2a9cd3 100644 --- a/packages/query-engine/src/registry/queries.ts +++ b/packages/query-engine/src/registry/queries.ts @@ -37,6 +37,7 @@ import type { WebAnalyticsTimeseriesRequest, WebAnalyticsPageviewsRequest, WebAnalyticsPagesRequest, + WebAnalyticsEventsRequest, WebAnalyticsBreakdownsRequest, } from "@maple/domain/http" import { Match } from "effect" @@ -640,6 +641,7 @@ const webAnalyticsFilters = ( readonly utmMedium?: string readonly utmCampaign?: string readonly visitorType?: "new" | "returning" + readonly eventName?: string }, useWebEvents: boolean, ): CH.WebAnalyticsFilters => ({ @@ -655,6 +657,7 @@ const webAnalyticsFilters = ( utmMedium: payload.utmMedium, utmCampaign: payload.utmCampaign, visitorType: payload.visitorType, + eventName: payload.eventName, useWebEvents, }) @@ -726,6 +729,23 @@ const webAnalyticsPagesDef = (useWebEvents: boolean) => ({ export const webAnalyticsPages = defineQuery(webAnalyticsPagesDef(true)) export const webAnalyticsPagesRaw = defineQuery(webAnalyticsPagesDef(false)) +const webAnalyticsEventsDef = (useWebEvents: boolean) => ({ + id: "webAnalyticsEvents" as const, + profile: "aggregation" as const, + cache: timeRangeCache, + compile: (payload: WebAnalyticsEventsRequest, orgId: string) => + CH.compile( + CH.webAnalyticsEventsQuery({ + ...webAnalyticsFilters(payload, useWebEvents), + limit: payload.limit, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), +}) + +export const webAnalyticsEvents = defineQuery(webAnalyticsEventsDef(true)) +export const webAnalyticsEventsRaw = defineQuery(webAnalyticsEventsDef(false)) + const webAnalyticsBreakdownsDef = (useWebEvents: boolean) => ({ id: "webAnalyticsBreakdowns" as const, profile: "aggregation" as const,