diff --git a/apps/api/src/routes/v1/query-engine.http.ts b/apps/api/src/routes/v1/query-engine.http.ts index a4cd723fd..a77fb7312 100644 --- a/apps/api/src/routes/v1/query-engine.http.ts +++ b/apps/api/src/routes/v1/query-engine.http.ts @@ -57,6 +57,11 @@ import { WorkloadDetailSummaryResponse, WorkloadInfraTimeseriesResponse, WorkloadFacetsResponse, + WebAnalyticsSummaryResponse, + WebAnalyticsTimeseriesResponse, + WebAnalyticsPageviewsResponse, + WebAnalyticsPagesResponse, + WebAnalyticsBreakdownsResponse, CommitSha, FingerprintHash, ServiceName, @@ -1520,6 +1525,107 @@ export const HttpQueryEngineLive = HttpApiBuilder.group(MapleApi, "queryEngine", return new WorkloadFacetsResponse({ data: buckets }) }), ) + .handle("webAnalyticsSummary", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const row = yield* runQueryFirst(Queries.webAnalyticsSummary, tenant, payload) + // A window with no sessions returns no rows at all, not a row of + // zeroes — the page needs the zeroes to render its empty state. + return new WebAnalyticsSummaryResponse({ + data: { + visitors: Number(row?.visitors) || 0, + sessions: Number(row?.sessions) || 0, + newSessions: Number(row?.newSessions) || 0, + bouncedSessions: Number(row?.bouncedSessions) || 0, + identifiedSessions: Number(row?.identifiedSessions) || 0, + avgDurationMs: Number(row?.avgDurationMs) || 0, + }, + }) + }), + ) + .handle("webAnalyticsTimeseries", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.webAnalyticsTimeseries, tenant, payload) + return new WebAnalyticsTimeseriesResponse({ + data: rows.map((row) => ({ + bucket: String(row.bucket), + visitors: Number(row.visitors) || 0, + sessions: Number(row.sessions) || 0, + newSessions: Number(row.newSessions) || 0, + })), + }) + }), + ) + .handle("webAnalyticsPageviews", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.webAnalyticsPageviews, tenant, payload) + return new WebAnalyticsPageviewsResponse({ + data: rows.map((row) => ({ + bucket: String(row.bucket), + pageViews: Number(row.pageViews) || 0, + sessions: Number(row.sessions) || 0, + })), + }) + }), + ) + .handle("webAnalyticsPages", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.webAnalyticsPages, tenant, payload) + return new WebAnalyticsPagesResponse({ + data: rows.map((row) => ({ + host: String(row.host), + pagePath: String(row.pagePath), + pageViews: Number(row.pageViews) || 0, + sessions: Number(row.sessions) || 0, + })), + }) + }), + ) + .handle("webAnalyticsBreakdowns", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const rows = yield* runQuery(Queries.webAnalyticsBreakdowns, tenant, payload) + const buckets = { + referrerHosts: [] as Array<{ name: string; count: number }>, + countries: [] as Array<{ name: string; count: number }>, + deviceTypes: [] as Array<{ name: string; count: number }>, + browsers: [] as Array<{ name: string; count: number }>, + operatingSystems: [] as Array<{ name: string; count: number }>, + languages: [] as Array<{ name: string; count: number }>, + utmSources: [] as Array<{ name: string; count: number }>, + utmMediums: [] as Array<{ name: string; count: number }>, + utmCampaigns: [] as Array<{ name: string; count: number }>, + entryPaths: [] as Array<{ name: string; count: number }>, + exitPaths: [] as Array<{ name: string; count: number }>, + hosts: [] as Array<{ name: string; count: number }>, + } + // facetType → response key. A table rather than a twelve-arm switch: + // the mapping is the whole content of this step, and the keys have to + // stay in step with WebAnalyticsFacetKey in the query builder. + const bucketOf: Record = { + referrerHost: "referrerHosts", + country: "countries", + deviceType: "deviceTypes", + browserName: "browsers", + osName: "operatingSystems", + language: "languages", + utmSource: "utmSources", + utmMedium: "utmMediums", + utmCampaign: "utmCampaigns", + entryPath: "entryPaths", + exitPath: "exitPaths", + host: "hosts", + } + for (const row of rows) { + const key = bucketOf[row.facetType] + if (key) buckets[key].push({ name: String(row.name), count: Number(row.count) || 0 }) + } + return new WebAnalyticsBreakdownsResponse({ data: buckets }) + }), + ) .handle("executeRawSql", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/ingest/alchemy.run.ts b/apps/ingest/alchemy.run.ts index fe3d550fd..d30e24cb2 100644 --- a/apps/ingest/alchemy.run.ts +++ b/apps/ingest/alchemy.run.ts @@ -281,6 +281,16 @@ export const createMapleIngest = ({ stage, domains, region }: CreateMapleIngestO TINYBIRD_HOST: requireEnv("TINYBIRD_HOST"), INGEST_KEY_STORE_BACKEND: "postgres", + // Trust `Cf-IPCountry` on inbound requests, which is what gates + // `derive_country` in `apps/ingest/src/main.rs` and therefore whether + // `session_replays.Country` is ever non-empty. Safe here specifically + // because the ALB security group only admits Cloudflare's proxy ranges + // (see `albSecurityGroup` above), so the header cannot be + // client-supplied. Left unset until now, which is why every session + // recorded before this deploy has `Country = ''` — the gateway never + // stores a client IP, so there is nothing to backfill from. + MAPLE_INGEST_TRUST_PROXY_GEO: "true", + INGEST_QUEUE_MAX_BYTES: String(WAL_MAX_BYTES), INGEST_WAL_SHARDS: String(WAL_SHARDS), diff --git a/apps/web/src/api/warehouse/web-analytics.ts b/apps/web/src/api/warehouse/web-analytics.ts new file mode 100644 index 000000000..7300c54a0 --- /dev/null +++ b/apps/web/src/api/warehouse/web-analytics.ts @@ -0,0 +1,254 @@ +// --------------------------------------------------------------------------- +// Web Analytics query wrappers +// +// One filter schema shared by all five 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. +// --------------------------------------------------------------------------- + +import { Effect, Schema } from "effect" +import { + WebAnalyticsBreakdownsRequest, + WebAnalyticsPagesRequest, + WebAnalyticsPageviewsRequest, + WebAnalyticsSummaryRequest, + WebAnalyticsTimeseriesRequest, +} from "@maple/domain/http" +import { MapleApiAtomClient } from "@/lib/services/common/atom-client" +import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" + +const WebAnalyticsFilterFields = { + host: Schema.optional(Schema.String), + pagePath: Schema.optional(Schema.String), + referrerHost: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + deviceType: Schema.optional(Schema.String), + browserName: Schema.optional(Schema.String), + osName: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + visitorType: Schema.optional(Schema.Literals(["new", "returning"])), +} as const + +const TimeWindowFields = { + startTime: WarehouseDateTimeString, + endTime: WarehouseDateTimeString, +} as const + +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) + +const WebAnalyticsSummaryInputSchema = Schema.Struct({ + ...TimeWindowFields, + ...WebAnalyticsFilterFields, +}) + +const WebAnalyticsTimeseriesInputSchema = Schema.Struct({ + ...TimeWindowFields, + ...WebAnalyticsFilterFields, + bucketSeconds: Schema.optional(PositiveInt), +}) + +const WebAnalyticsPagesInputSchema = Schema.Struct({ + ...TimeWindowFields, + ...WebAnalyticsFilterFields, + limit: Schema.optional(PositiveInt), +}) + +const WebAnalyticsBreakdownsInputSchema = Schema.Struct({ + ...TimeWindowFields, + ...WebAnalyticsFilterFields, + limitPerDimension: Schema.optional(PositiveInt), +}) + +export type GetWebAnalyticsSummaryInput = (typeof WebAnalyticsSummaryInputSchema)["Encoded"] +export type GetWebAnalyticsTimeseriesInput = (typeof WebAnalyticsTimeseriesInputSchema)["Encoded"] +export type GetWebAnalyticsPagesInput = (typeof WebAnalyticsPagesInputSchema)["Encoded"] +export type GetWebAnalyticsBreakdownsInput = (typeof WebAnalyticsBreakdownsInputSchema)["Encoded"] + +export interface WebAnalyticsSummary { + visitors: number + sessions: number + newSessions: number + bouncedSessions: number + identifiedSessions: number + avgDurationMs: number + /** + * Share of sessions whose SDK build posts the analytics block, i.e. the share + * of traffic every visitor-level number on the page actually describes. The + * page surfaces it rather than presenting a partial count as the whole. + */ + coverage: number + /** + * Bounces over **identified** sessions, 0–1. Not over all sessions: page views + * are part of the analytics block, so sessions without it report zero of them + * and would every one count as a bounce. `null` when nothing reports page + * views — the honest answer there is "unknown", not 0% and not 100%. + */ + bounceRate: number | null +} + +export interface WebAnalyticsTimeseriesPoint { + bucket: string + visitors: number + sessions: number + newSessions: number +} + +export interface WebAnalyticsPageviewsPoint { + bucket: string + pageViews: number + sessions: number +} + +export interface WebAnalyticsPage { + host: string + pagePath: string + pageViews: number + sessions: number +} + +export interface WebAnalyticsFacetRow { + name: string + count: number +} + +export interface WebAnalyticsBreakdowns { + referrerHosts: ReadonlyArray + countries: ReadonlyArray + deviceTypes: ReadonlyArray + browsers: ReadonlyArray + operatingSystems: ReadonlyArray + languages: ReadonlyArray + utmSources: ReadonlyArray + utmMediums: ReadonlyArray + utmCampaigns: ReadonlyArray + entryPaths: ReadonlyArray + exitPaths: ReadonlyArray + hosts: ReadonlyArray +} + +const ratio = (numerator: number, denominator: number): number => + denominator > 0 ? numerator / denominator : 0 + +export function getWebAnalyticsSummary({ data }: { data: GetWebAnalyticsSummaryInput }) { + return getWebAnalyticsSummaryEffect({ data }) +} + +const getWebAnalyticsSummaryEffect = Effect.fn("QueryEngine.getWebAnalyticsSummary")(function* ({ + data, +}: { + data: GetWebAnalyticsSummaryInput +}) { + const input = yield* decodeInput(WebAnalyticsSummaryInputSchema, data, "getWebAnalyticsSummary") + + const result = yield* runWarehouseQuery("webAnalyticsSummary", () => + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.webAnalyticsSummary({ + payload: new WebAnalyticsSummaryRequest(input), + }) + }), + ) + + const row = result.data + return { + ...row, + coverage: ratio(row.identifiedSessions, row.sessions), + bounceRate: row.identifiedSessions > 0 ? row.bouncedSessions / row.identifiedSessions : null, + } satisfies WebAnalyticsSummary +}) + +export function getWebAnalyticsTimeseries({ data }: { data: GetWebAnalyticsTimeseriesInput }) { + return getWebAnalyticsTimeseriesEffect({ data }) +} + +const getWebAnalyticsTimeseriesEffect = Effect.fn("QueryEngine.getWebAnalyticsTimeseries")(function* ({ + data, +}: { + data: GetWebAnalyticsTimeseriesInput +}) { + const input = yield* decodeInput(WebAnalyticsTimeseriesInputSchema, data, "getWebAnalyticsTimeseries") + + const result = yield* runWarehouseQuery("webAnalyticsTimeseries", () => + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.webAnalyticsTimeseries({ + payload: new WebAnalyticsTimeseriesRequest(input), + }) + }), + ) + + return { data: result.data satisfies ReadonlyArray } +}) + +export function getWebAnalyticsPageviews({ data }: { data: GetWebAnalyticsTimeseriesInput }) { + return getWebAnalyticsPageviewsEffect({ data }) +} + +const getWebAnalyticsPageviewsEffect = Effect.fn("QueryEngine.getWebAnalyticsPageviews")(function* ({ + data, +}: { + data: GetWebAnalyticsTimeseriesInput +}) { + const input = yield* decodeInput(WebAnalyticsTimeseriesInputSchema, data, "getWebAnalyticsPageviews") + + const result = yield* runWarehouseQuery("webAnalyticsPageviews", () => + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.webAnalyticsPageviews({ + payload: new WebAnalyticsPageviewsRequest(input), + }) + }), + ) + + return { data: result.data satisfies ReadonlyArray } +}) + +export function getWebAnalyticsPages({ data }: { data: GetWebAnalyticsPagesInput }) { + return getWebAnalyticsPagesEffect({ data }) +} + +const getWebAnalyticsPagesEffect = Effect.fn("QueryEngine.getWebAnalyticsPages")(function* ({ + data, +}: { + data: GetWebAnalyticsPagesInput +}) { + const input = yield* decodeInput(WebAnalyticsPagesInputSchema, data, "getWebAnalyticsPages") + + const result = yield* runWarehouseQuery("webAnalyticsPages", () => + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.webAnalyticsPages({ + payload: new WebAnalyticsPagesRequest(input), + }) + }), + ) + + return { data: result.data satisfies ReadonlyArray } +}) + +export function getWebAnalyticsBreakdowns({ data }: { data: GetWebAnalyticsBreakdownsInput }) { + return getWebAnalyticsBreakdownsEffect({ data }) +} + +const getWebAnalyticsBreakdownsEffect = Effect.fn("QueryEngine.getWebAnalyticsBreakdowns")(function* ({ + data, +}: { + data: GetWebAnalyticsBreakdownsInput +}) { + const input = yield* decodeInput(WebAnalyticsBreakdownsInputSchema, data, "getWebAnalyticsBreakdowns") + + const result = yield* runWarehouseQuery("webAnalyticsBreakdowns", () => + Effect.gen(function* () { + const client = yield* MapleApiAtomClient + return yield* client.queryEngine.webAnalyticsBreakdowns({ + payload: new WebAnalyticsBreakdownsRequest(input), + }) + }), + ) + + return result.data satisfies WebAnalyticsBreakdowns +}) diff --git a/apps/web/src/components/analytics/analytics-breakdown-panel.tsx b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx new file mode 100644 index 000000000..7966ede08 --- /dev/null +++ b/apps/web/src/components/analytics/analytics-breakdown-panel.tsx @@ -0,0 +1,249 @@ +import { useDeferredValue, useMemo, useState } from "react" + +import { cn } from "@maple/ui/lib/utils" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" + +import { ColumnHead, DataTable, useTableSort } from "../infra/primitives/data-table" +import { shareTint } from "../infra/primitives/share-tint" +import type { WebAnalyticsFacetRow } from "@/api/warehouse/web-analytics" +import type { AnalyticsFilterKey } from "./filters" + +export interface BreakdownDimension { + /** Tab label. */ + readonly tab: string + /** Rows for this dimension, already ranked by the server. */ + readonly rows: ReadonlyArray + /** Which URL filter a row click sets. */ + readonly filterKey: AnalyticsFilterKey + /** Singular noun for the value column head and the empty state. */ + readonly noun: string + /** Plural of `noun`. Given explicitly because `+ "s"` mangles half of them. */ + readonly nounPlural: string + /** + * Shown in place of the table when `rows` is empty. Distinguishes "this + * dimension is not being collected" from "no traffic matched", which for a + * dimension like Country is the difference between a config gap and a fact. + */ + readonly emptyMessage?: string + /** Row-name → display text, for codes whose label differs (country, language). */ + readonly formatValue?: (name: string) => string + /** + * True when the column belongs to the migration-0011 analytics block, so the + * panel's coverage caveat applies to it. + * + * Not every dimension here is affected, and saying otherwise is a lie the UI + * can be caught in: `BrowserName`, `OsName`, `DeviceType` and `Country` predate + * that migration and are populated for every session, while `Referrer`, `Utm*`, + * `EntryPath`, `Host` and `Language` are not. Attaching one panel-wide caveat + * told an operator that browser data covered a fraction of sessions while the + * filter sidebar beside it counted 41k Chrome sessions. + */ + readonly coverageDependent?: boolean +} + +type SortKey = "name" | "count" + +/** + * Identity label for dimensions with no `formatValue`. A module constant so the + * `?? identityLabel` fallback has a stable identity across renders and can be a + * memo dependency; an inline `(name) => name` would be a fresh closure each time. + */ +const identityLabel = (name: string): string => name + +interface AnalyticsBreakdownPanelProps { + title: string + dimensions: ReadonlyArray + /** Currently-set filters, so the selected row can render as selected. */ + activeValue: (key: AnalyticsFilterKey) => string | undefined + onToggleFilter: (key: AnalyticsFilterKey, value: string) => void + waiting?: boolean + /** + * The coverage caveat, applied only to dimensions marked `coverageDependent`. + */ + footnote?: string +} + +/** + * The ranked-dimension card: tabs across the top, a searchable table below, each + * row tinted to its share of the listed total and clickable to filter the page. + * + * Two things worth knowing about the numbers it shows: + * + * - The share is of the **listed** total, not of all traffic. The server returns + * a top-N per dimension, so the tail is absent and the shares of the visible + * rows do not sum to 1. The footer says as much rather than implying they do. + * - Counts are sessions, not visitors. Every branch of the breakdown query + * counts `uniq(SessionId)` — see that file's header for why counting rows + * would double-count on a ReplacingMergeTree. + * + * The row filter is local and network-free (`useDeferredValue` over the already- + * fetched rows): the point is to find a known value in a 50-row list, and a + * round trip per keystroke would be slower and would move the ranking underneath + * the person typing. + */ +export function AnalyticsBreakdownPanel({ + title, + dimensions, + activeValue, + onToggleFilter, + waiting, + footnote, +}: AnalyticsBreakdownPanelProps) { + // `null` means "nobody has picked a tab yet", which is deliberately distinct + // from "tab 0". Landing on the first *populated* dimension is what stops a + // panel whose leading tab is empty for its own reasons (Countries, before geo + // is enabled) from presenting as broken while five populated tabs sit beside + // it — and that has to be derived during render, not seeded into state. + // `useState(firstPopulated)` would freeze the answer at mount, so a panel that + // mounted before its rows arrived, or whose rows changed under a new filter or + // time range, would keep pointing at a tab that is now empty. + const [pickedTab, setPickedTab] = useState(null) + const [query, setQuery] = useState("") + const deferredQuery = useDeferredValue(query) + + const firstPopulated = Math.max( + dimensions.findIndex((dim) => dim.rows.length > 0), + 0, + ) + // An explicit pick wins even when it lands on an empty dimension — the person + // asked for that tab, and silently bouncing them off it would be worse than an + // honest empty state. + const activeTab = pickedTab ?? firstPopulated + const dimension = dimensions[activeTab] ?? dimensions[0]! + const selected = activeValue(dimension.filterKey) + + const label = dimension.formatValue ?? identityLabel + + const rows = useMemo(() => { + const total = dimension.rows.reduce((sum, row) => sum + row.count, 0) + const needle = deferredQuery.trim().toLowerCase() + return dimension.rows + .filter((row) => (needle ? label(row.name).toLowerCase().includes(needle) : true)) + .map((row) => ({ ...row, share: total > 0 ? row.count / total : 0 })) + }, [dimension, deferredQuery, label]) + + const dimensionFootnote = dimension.coverageDependent ? footnote : undefined + + const { sorted, sortKey, sortDir, handleSort } = useTableSort(rows, { + initialKey: "count" as SortKey, + stringKeys: ["name"], + }) + + return ( +
+
+
+ {title} + {dimensions.map((dim, index) => ( + + ))} +
+ setQuery(event.target.value)} + placeholder={`Filter ${dimension.nounPlural}`} + className="h-6 w-40 rounded-sm border bg-background px-2 text-[11px] placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+ + {dimension.rows.length === 0 ? ( +
+ {dimension.emptyMessage ?? `No ${dimension.noun} data in the selected window.`} +
+ ) : ( + + + + label={dimension.noun} + width="flex-1 min-w-0" + sortKey="name" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + + label="Sessions" + width="w-24" + align="right" + sortKey="count" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + label="Share" width="w-16" align="right" /> + + {sorted.length === 0 ? ( + No {dimension.noun} matches that filter. + ) : ( + sorted.map((row) => { + const isSelected = selected === row.name + return ( + + ) + }) + )} + + )} + + {/* The share sentence describes a ranking, so it is suppressed when there is + nothing ranked — the coverage footnote is the useful half there. */} + {dimensionFootnote || dimension.rows.length > 0 ? ( +
+ {dimension.rows.length > 0 + ? `Share is of the ${formatNumber(dimension.rows.length)} listed ${dimension.nounPlural}, not of all traffic.` + : null} + {dimensionFootnote ? ` ${dimensionFootnote}` : ""} +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-filter-sidebar.tsx b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx new file mode 100644 index 000000000..f1cd63c5b --- /dev/null +++ b/apps/web/src/components/analytics/analytics-filter-sidebar.tsx @@ -0,0 +1,177 @@ +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 { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" +import { + FilterSection, + SearchableFilterSection, + SingleCheckboxFilter, + type FilterOption, +} from "@/components/filters/filter-section" +import { FILTER_SECTION_LABEL } from "@maple/ui/components/filters/filter-styles" +import { + FilterSidebarBody, + FilterSidebarError, + FilterSidebarFrame, + FilterSidebarHeader, + FilterSidebarLoading, +} from "@/components/filters/filter-sidebar" +import { + FILTER_SECTION_LABEL as FILTER_SECTION_LABEL_TEXT, + type AnalyticsFilterKey, + type AnalyticsFilters, +} from "./filters" +import { countryLabel, languageLabel } from "./labels" + +interface AnalyticsFilterSidebarProps { + breakdownsResult: Result.Result + filters: AnalyticsFilters + onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void + onClearFilters: () => void +} + +const toOptions = (rows: ReadonlyArray<{ name: string; count: number }>): ReadonlyArray => + rows.map((row) => ({ name: row.name, count: row.count })) + +export function AnalyticsFilterSidebar({ + breakdownsResult, + filters, + onFilterChange, + onClearFilters, +}: AnalyticsFilterSidebarProps) { + return Result.builder(breakdownsResult) + .onInitial(() => ) + .onError((error) => ) + .onSuccess((breakdowns, result) => ( + + )) + .render() +} + +function AnalyticsFilterSidebarView({ + breakdowns, + waiting, + filters, + onFilterChange, + onClearFilters, +}: { + breakdowns: WebAnalyticsBreakdowns + waiting: boolean + filters: AnalyticsFilters + onFilterChange: (key: AnalyticsFilterKey, value: string | undefined) => void + onClearFilters: () => void +}) { + /** + * The shared `FilterSection` is multi-select; these filters are single-valued + * (see `./filters`). Adapt rather than fork the section: hand it a 0-or-1 + * element array, and on change keep whichever value is new. Ticking a second + * box therefore *moves* the selection instead of unioning it, which is what a + * single-valued filter means. + */ + const single = (key: AnalyticsFilterKey) => ({ + selected: filters[key] ? [filters[key]!] : [], + onChange: (next: string[]) => { + const current = filters[key] + onFilterChange(key, next.find((value) => value !== current) ?? undefined) + }, + }) + + const canClear = Object.values(filters).some(Boolean) + + return ( + + + + {/* Two exclusive checkboxes rather than a FilterSection: that component + requires a `count` per option and always renders it, and this filter has + no per-option count to give — the sidebar's other counts come from + server-side facet branches, and a hard-coded 0 beside "New" reads as + "zero new visitors" rather than as "not counted". Unchecking both means + all visitors. */} +
+

+ {FILTER_SECTION_LABEL_TEXT.visitorType} +

+ onFilterChange("visitorType", on ? "new" : undefined)} + /> + onFilterChange("visitorType", on ? "returning" : undefined)} + /> +
+ + + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-pages-panel.tsx b/apps/web/src/components/analytics/analytics-pages-panel.tsx new file mode 100644 index 000000000..472462f6d --- /dev/null +++ b/apps/web/src/components/analytics/analytics-pages-panel.tsx @@ -0,0 +1,178 @@ +import { useDeferredValue, useMemo, useState } from "react" + +import { cn } from "@maple/ui/lib/utils" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" + +import { ColumnHead, DataTable, useTableSort } from "../infra/primitives/data-table" +import { shareTint } from "../infra/primitives/share-tint" +import type { WebAnalyticsPage } from "@/api/warehouse/web-analytics" + +type SortKey = "pagePath" | "host" | "pageViews" | "sessions" + +interface AnalyticsPagesPanelProps { + pages: ReadonlyArray + selectedPath?: string + onTogglePath: (pagePath: string) => void + waiting?: boolean + /** + * True once more than one site appears. Not merely cosmetic: several sites can + * share a path (`/editor/`, `/settings`), and then the host is the only thing + * telling those rows apart — so the column is never breakpoint-hidden when + * this is on. + */ + showHost?: boolean +} + +/** + * Most-viewed pages. + * + * The one panel on this page with full coverage: it reads `session_events` + * navigation rows, which every SDK build emits, so its page-view counts describe + * all traffic rather than the analytics-block subset the visitor-level panels + * measure. `pageViews` is a real per-view count; `sessions` is how many distinct + * sessions reached the page. + */ +export function AnalyticsPagesPanel({ + pages, + selectedPath, + onTogglePath, + waiting, + showHost, +}: AnalyticsPagesPanelProps) { + const [query, setQuery] = useState("") + const deferredQuery = useDeferredValue(query) + + const rows = useMemo(() => { + const total = pages.reduce((sum, page) => sum + page.pageViews, 0) + const needle = deferredQuery.trim().toLowerCase() + return pages + .filter((page) => + needle + ? page.pagePath.toLowerCase().includes(needle) || page.host.toLowerCase().includes(needle) + : true, + ) + .map((page) => ({ ...page, share: total > 0 ? page.pageViews / total : 0 })) + }, [pages, deferredQuery]) + + const { sorted, sortKey, sortDir, handleSort } = useTableSort(rows, { + initialKey: "pageViews" as SortKey, + stringKeys: ["pagePath", "host"], + }) + + return ( +
+
+ Top pages + setQuery(event.target.value)} + placeholder="Filter pages" + className="h-6 w-40 rounded-sm border bg-background px-2 text-[11px] placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+ + {pages.length === 0 ? ( +
+ No page views in the selected window. +
+ ) : ( + + + + label="Page" + width="flex-1 min-w-0" + sortKey="pagePath" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + {showHost ? ( + + label="Site" + width="w-32 sm:w-40" + sortKey="host" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + ) : null} + + label="Views" + width="w-20" + align="right" + sortKey="pageViews" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + + label="Sessions" + width="w-24" + align="right" + sortKey="sessions" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + /> + label="Share" width="w-16" align="right" /> + + {sorted.length === 0 ? ( + No page matches that filter. + ) : ( + sorted.map((row) => { + const isSelected = selectedPath === row.pagePath + return ( + + ) + }) + )} + + )} + +
+ Page views across every session, including those whose SDK build predates the visitor-level + analytics fields. +
+
+ ) +} diff --git a/apps/web/src/components/analytics/analytics-traffic-chart.tsx b/apps/web/src/components/analytics/analytics-traffic-chart.tsx new file mode 100644 index 000000000..e659d14fc --- /dev/null +++ b/apps/web/src/components/analytics/analytics-traffic-chart.tsx @@ -0,0 +1,164 @@ +import { useId, useMemo } from "react" +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts" + +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@maple/ui/components/ui/chart" +import { formatNumber } from "@maple/ui/lib/format" + +import type { WebAnalyticsPageviewsPoint, WebAnalyticsTimeseriesPoint } from "@/api/warehouse/web-analytics" +import { CHART_EMPTY_MESSAGE, CHART_GRID_DASH, makeBucketLabeler } from "../infra/chart-utils" +import { CHART_HEIGHT, ChartCard, ChartCardMessage } from "../infra/primitives/chart-card" + +const CHART_CONFIG = { + pageViews: { label: "Page views", color: "var(--chart-2)" }, + visitors: { label: "Visitors", color: "var(--chart-1)" }, +} satisfies ChartConfig + +interface AnalyticsTrafficChartProps { + visitorPoints: ReadonlyArray + pageviewPoints: ReadonlyArray + syncId?: string +} + +/** + * Page views and unique visitors on one set of axes. + * + * The two series come from different tables with different coverage — page views + * from every session, visitors only from sessions whose SDK build posts the + * analytics block — so they are deliberately *not* stacked and the visitors area + * is drawn over the page-view area rather than under it. Reading visitors as a + * subset of page views is the correct reading; reading their sum as anything is + * not. + * + * Buckets are outer-joined on the union of both series' timestamps, so a window + * where one table has data and the other doesn't shows a gap in one line instead + * of shifting the other one sideways. + */ +export function AnalyticsTrafficChart({ visitorPoints, pageviewPoints, syncId }: AnalyticsTrafficChartProps) { + const gradientPrefix = useId().replace(/:/g, "") + + const data = useMemo(() => { + const byBucket = new Map() + for (const point of pageviewPoints) { + const entry = byBucket.get(point.bucket) ?? { visitors: 0, pageViews: 0 } + entry.pageViews = point.pageViews + byBucket.set(point.bucket, entry) + } + for (const point of visitorPoints) { + const entry = byBucket.get(point.bucket) ?? { visitors: 0, pageViews: 0 } + entry.visitors = point.visitors + byBucket.set(point.bucket, entry) + } + const buckets = [...byBucket.keys()].sort() + const label = makeBucketLabeler(buckets) + return buckets.map((bucket) => ({ label: label(bucket), ...byBucket.get(bucket)! })) + }, [visitorPoints, pageviewPoints]) + + const totals = useMemo( + () => ({ + pageViews: data.reduce((sum, row) => sum + row.pageViews, 0), + visitors: visitorPoints.reduce((sum, row) => sum + row.visitors, 0), + }), + [data, visitorPoints], + ) + + // A flat-zero visitors series means no session in the window reports a visitor + // id, not that nobody visited — the page-view series right next to it proves + // otherwise. Drop the series rather than draw a line along the axis, and label + // it so the absence reads as unreported instead of as zero. + const hasVisitors = totals.visitors > 0 + const seriesKeys = hasVisitors ? (["pageViews", "visitors"] as const) : (["pageViews"] as const) + + const legend = ( + <> + {(["pageViews", "visitors"] as const).map((key) => ( + + + {CHART_CONFIG[key].label} + + {key === "visitors" && !hasVisitors ? "not reported" : formatNumber(totals[key])} + + + ))} + + ) + + return ( + + {data.length === 0 ? ( + {CHART_EMPTY_MESSAGE} + ) : ( + + + + {seriesKeys.map((key) => ( + + + + + ))} + + + + formatNumber(value)} + className="text-[10px]" + /> + } /> + {/* Page views first so the smaller visitors area paints on top of it. */} + {seriesKeys.map((key) => ( + + ))} + + + )} + + ) +} diff --git a/apps/web/src/components/analytics/filters.ts b/apps/web/src/components/analytics/filters.ts new file mode 100644 index 000000000..2c49a35c9 --- /dev/null +++ b/apps/web/src/components/analytics/filters.ts @@ -0,0 +1,122 @@ +// Shared filter vocabulary for the Web Analytics page. +// +// Every filter is single-valued, matching the query builder's filter surface +// (packages/query-engine/src/ch/queries/web-analytics.ts). Single rather than +// multi-select is a deliberate narrowing: the panels answer "how does this slice +// behave", and the honest way to compare two countries is two looks, not a +// union that neither the KPI row nor the coverage figure could attribute. + +import { Schema } from "effect" + +/** URL search-param fields. Spread into the route's `validateSearch` schema. */ +export const analyticsFilterSearchFields = { + host: Schema.optional(Schema.String), + pagePath: Schema.optional(Schema.String), + referrerHost: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + deviceType: Schema.optional(Schema.String), + browserName: Schema.optional(Schema.String), + osName: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + visitorType: Schema.optional(Schema.Literals(["new", "returning"])), +} + +export interface AnalyticsFilters { + host?: string + pagePath?: string + referrerHost?: string + country?: string + deviceType?: string + browserName?: string + osName?: string + language?: string + utmSource?: string + utmMedium?: string + utmCampaign?: string + visitorType?: "new" | "returning" +} + +export type AnalyticsFilterKey = keyof AnalyticsFilters + +/** Filter key → the singular noun used in chips. Short: these render in 10px mono. */ +export const FILTER_CHIP_LABEL: Record = { + host: "host", + pagePath: "page", + referrerHost: "referrer", + country: "country", + deviceType: "device", + browserName: "browser", + osName: "os", + language: "lang", + utmSource: "utm_source", + utmMedium: "utm_medium", + utmCampaign: "utm_campaign", + visitorType: "visitor", +} + +/** Filter key → sidebar section heading. Sentence case, matching the rest of the app. */ +export const FILTER_SECTION_LABEL: Record = { + host: "Site", + pagePath: "Page", + referrerHost: "Referrer", + country: "Country", + deviceType: "Device", + browserName: "Browser", + osName: "Operating system", + language: "Language", + utmSource: "UTM source", + utmMedium: "UTM medium", + utmCampaign: "UTM campaign", + visitorType: "Visitor", +} + +const FILTER_KEYS = Object.keys(FILTER_CHIP_LABEL) as ReadonlyArray + +/** Pull just the filter fields out of the route's search object. */ +export const filtersFromSearch = (search: Record): AnalyticsFilters => { + const out: AnalyticsFilters = {} + for (const key of FILTER_KEYS) { + const value = search[key] + if (typeof value !== "string" || value === "") continue + if (key === "visitorType") { + if (value === "new" || value === "returning") out.visitorType = value + } else { + out[key] = value + } + } + return out +} + +export interface ActiveFilterChip { + readonly key: AnalyticsFilterKey + readonly value: string + /** What the chip reads, e.g. `country:DE`. */ + readonly label: string +} + +/** Flatten the filter object into one removable chip per set filter. */ +export const activeFilterChips = (filters: AnalyticsFilters): ReadonlyArray => + FILTER_KEYS.flatMap((key) => { + const value = filters[key] + return value ? [{ key, value, label: `${FILTER_CHIP_LABEL[key]}:${value}` }] : [] + }) + +/** + * Clicking the already-selected value clears it. A breakdown row is the only + * affordance for un-setting a filter you set from the same table, so making the + * click a toggle is what keeps the table navigable in both directions. + */ +export const toggleFilterValue = (current: string | undefined, value: string): string | undefined => + current === value ? undefined : value + +/** + * `ReferrerHost` and friends are `''` for sessions that never populated them, + * and those rows are dropped server-side rather than shown as a blank label. A + * dimension nobody sends therefore arrives as an empty list — which is the + * signal the panels use to say "not collected" instead of "zero". + */ +export const DIRECT_REFERRER_NOTE = + "Sessions with no referrer are excluded rather than bucketed as direct — an empty referrer also covers internal navigation and Referrer-Policy suppression." diff --git a/apps/web/src/components/analytics/labels.ts b/apps/web/src/components/analytics/labels.ts new file mode 100644 index 000000000..b8230de3b --- /dev/null +++ b/apps/web/src/components/analytics/labels.ts @@ -0,0 +1,44 @@ +// Display labels for the coded dimensions. +// +// `Intl.DisplayNames` is the whole implementation: the browser already ships the +// CLDR region and language tables, so shipping our own map would be a few KB of +// data that goes stale. Both are constructed lazily and memoized, because +// constructing one per row is the expensive part, not the lookup. + +const displayNames = (type: "region" | "language"): Intl.DisplayNames | undefined => { + try { + return new Intl.DisplayNames(undefined, { type, fallback: "none" }) + } catch { + return undefined + } +} + +let regionNames: Intl.DisplayNames | undefined | null = null +let languageNames: Intl.DisplayNames | undefined | null = null + +/** `DE` → `Germany 🇩🇪`, falling back to the raw code where CLDR has no entry. */ +export const countryLabel = (code: string): string => { + if (regionNames === null) regionNames = displayNames("region") + // Only well-formed two-letter codes reach the flag path; the gateway already + // rejects anything else (`derive_country` in apps/ingest/src/main.rs), but a + // stray value must render as itself rather than as mojibake. + if (!/^[A-Za-z]{2}$/.test(code)) return code + const upper = code.toUpperCase() + const name = regionNames?.of(upper) + const flag = String.fromCodePoint( + ...[...upper].map((char) => 0x1f1e6 + (char.charCodeAt(0) - "A".charCodeAt(0))), + ) + return name ? `${flag} ${name}` : `${flag} ${upper}` +} + +/** `en-US` → `American English`, falling back to the raw tag. */ +export const languageLabel = (tag: string): string => { + if (languageNames === null) languageNames = displayNames("language") + try { + return languageNames?.of(tag) ?? tag + } catch { + // `of` throws on a structurally invalid tag; the SDK forwards + // navigator.language verbatim, so one can arrive. + return tag + } +} diff --git a/apps/web/src/components/dashboard/nav-items.test.ts b/apps/web/src/components/dashboard/nav-items.test.ts index bbd5054d3..862db3ddb 100644 --- a/apps/web/src/components/dashboard/nav-items.test.ts +++ b/apps/web/src/components/dashboard/nav-items.test.ts @@ -50,7 +50,7 @@ describe("isNavItemActive", () => { }) describe("navGroups", () => { - it("renders nine top-level rows at rest", () => { + it("renders ten top-level rows at rest", () => { const rows = navGroups().flatMap((group) => group.items) expect(rows.map((item) => item.title)).toEqual([ "Overview", @@ -58,6 +58,7 @@ describe("navGroups", () => { "Service Map", "Infrastructure", "Explore", + "Web Analytics", "Dashboards", "Investigations", "Errors", diff --git a/apps/web/src/components/dashboard/nav-items.ts b/apps/web/src/components/dashboard/nav-items.ts index f458af301..d5ff94608 100644 --- a/apps/web/src/components/dashboard/nav-items.ts +++ b/apps/web/src/components/dashboard/nav-items.ts @@ -1,5 +1,6 @@ import { BellIcon, + ChartBarHorizontalIcon, ChartLineIcon, CircleWarningIcon, CloudflareIcon, @@ -134,6 +135,7 @@ export function navGroups(): NavGroup[] { label: "Analyze", items: [ exploreItem, + { title: "Web Analytics", href: "/analytics", icon: ChartBarHorizontalIcon }, { title: "Dashboards", href: "/dashboards", icon: GridSquareCirclePlusIcon }, ], }, 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 f4a23f0e1..f768cd4bc 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -111,6 +111,13 @@ import { getSessionTraceSummaries, listReplays, } from "@/api/warehouse/replays" +import { + getWebAnalyticsBreakdowns, + getWebAnalyticsPages, + getWebAnalyticsPageviews, + getWebAnalyticsSummary, + getWebAnalyticsTimeseries, +} from "@/api/warehouse/web-analytics" /** * The error union every warehouse server function fails with: the structured @@ -232,6 +239,29 @@ export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) +// Web analytics — one page, five 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, { + staleTime: 30_000, +}) + +export const webAnalyticsTimeseriesResultAtom = makeQueryAtomFamily(getWebAnalyticsTimeseries, { + staleTime: 30_000, +}) + +export const webAnalyticsPageviewsResultAtom = makeQueryAtomFamily(getWebAnalyticsPageviews, { + staleTime: 30_000, +}) + +export const webAnalyticsPagesResultAtom = makeQueryAtomFamily(getWebAnalyticsPages, { + staleTime: 30_000, +}) + +export const webAnalyticsBreakdownsResultAtom = makeQueryAtomFamily(getWebAnalyticsBreakdowns, { + staleTime: 30_000, +}) + export const getReplayResultAtom = makeQueryAtomFamily(getReplay, { staleTime: 60_000, }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6a555a832..d08b79183 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as WidgetLabRouteImport } from './routes/widget-lab' import { Route as AlertsIndexRouteImport } from './routes/alerts/index' import { Route as AlertsRuleIdRouteImport } from './routes/alerts/$ruleId' import { Route as AlertsCreateRouteImport } from './routes/alerts/create' +import { Route as AnalyticsIndexRouteImport } from './routes/analytics/index' import { Route as AnomaliesIndexRouteImport } from './routes/anomalies/index' import { Route as AnomaliesIncidentIdRouteImport } from './routes/anomalies/$incidentId' import { Route as DashboardsIndexRouteImport } from './routes/dashboards/index' @@ -214,6 +215,11 @@ const AlertsCreateRoute = AlertsCreateRouteImport.update({ path: '/alerts/create', getParentRoute: () => rootRouteImport, } as any) +const AnalyticsIndexRoute = AnalyticsIndexRouteImport.update({ + id: '/analytics/', + path: '/analytics/', + getParentRoute: () => rootRouteImport, +} as any) const AnomaliesIndexRoute = AnomaliesIndexRouteImport.update({ id: '/anomalies/', path: '/anomalies/', @@ -445,6 +451,7 @@ export interface FileRoutesByFullPath { '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute '/alerts/': typeof AlertsIndexRoute + '/analytics/': typeof AnalyticsIndexRoute '/anomalies/': typeof AnomaliesIndexRoute '/dashboards/': typeof DashboardsIndexRoute '/errors/': typeof ErrorsIndexRoute @@ -511,6 +518,7 @@ export interface FileRoutesByTo { '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute '/alerts': typeof AlertsIndexRoute + '/analytics': typeof AnalyticsIndexRoute '/anomalies': typeof AnomaliesIndexRoute '/dashboards': typeof DashboardsIndexRoute '/errors': typeof ErrorsIndexRoute @@ -578,6 +586,7 @@ export interface FileRoutesById { '/services/$serviceName': typeof ServicesServiceNameRoute '/traces/$traceId': typeof TracesTraceIdRoute '/alerts/': typeof AlertsIndexRoute + '/analytics/': typeof AnalyticsIndexRoute '/anomalies/': typeof AnomaliesIndexRoute '/dashboards/': typeof DashboardsIndexRoute '/errors/': typeof ErrorsIndexRoute @@ -646,6 +655,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/traces/$traceId' | '/alerts/' + | '/analytics/' | '/anomalies/' | '/dashboards/' | '/errors/' @@ -712,6 +722,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/traces/$traceId' | '/alerts' + | '/analytics' | '/anomalies' | '/dashboards' | '/errors' @@ -778,6 +789,7 @@ export interface FileRouteTypes { | '/services/$serviceName' | '/traces/$traceId' | '/alerts/' + | '/analytics/' | '/anomalies/' | '/dashboards/' | '/errors/' @@ -845,6 +857,7 @@ export interface RootRouteChildren { ServicesServiceNameRoute: typeof ServicesServiceNameRoute TracesTraceIdRoute: typeof TracesTraceIdRoute AlertsIndexRoute: typeof AlertsIndexRoute + AnalyticsIndexRoute: typeof AnalyticsIndexRoute AnomaliesIndexRoute: typeof AnomaliesIndexRoute DashboardsIndexRoute: typeof DashboardsIndexRoute ErrorsIndexRoute: typeof ErrorsIndexRoute @@ -1069,6 +1082,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AlertsCreateRouteImport parentRoute: typeof rootRouteImport } + '/analytics/': { + id: '/analytics/' + path: '/analytics' + fullPath: '/analytics/' + preLoaderRoute: typeof AnalyticsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/anomalies/': { id: '/anomalies/' path: '/anomalies' @@ -1365,6 +1385,7 @@ const rootRouteChildren: RootRouteChildren = { ServicesServiceNameRoute: ServicesServiceNameRoute, TracesTraceIdRoute: TracesTraceIdRoute, AlertsIndexRoute: AlertsIndexRoute, + AnalyticsIndexRoute: AnalyticsIndexRoute, AnomaliesIndexRoute: AnomaliesIndexRoute, DashboardsIndexRoute: DashboardsIndexRoute, ErrorsIndexRoute: ErrorsIndexRoute, diff --git a/apps/web/src/routes/analytics/index.tsx b/apps/web/src/routes/analytics/index.tsx new file mode 100644 index 000000000..5bc045463 --- /dev/null +++ b/apps/web/src/routes/analytics/index.tsx @@ -0,0 +1,475 @@ +import { Link, createFileRoute, useNavigate } from "@tanstack/react-router" +import { Schema } from "effect" +import { Result } from "@/lib/effect-atom" + +import { Button } from "@maple/ui/components/ui/button" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" + +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { QueryErrorState } from "@/components/common/query-error-state" +import { PageHero } from "@/components/infra/primitives/page-hero" +import { PlayRotateClockwiseIcon } from "@/components/icons" +import { StatRail, StatRailItem, StatRailLoading } from "@/components/infra/primitives/stat-rail" +import { chartBucketSeconds } from "@/components/infra/chart-utils" +import type { WebAnalyticsBreakdowns, WebAnalyticsSummary } from "@/api/warehouse/web-analytics" +import type { QueryAtomFailure } from "@/lib/services/atoms/warehouse-query-atoms" +import { + AnalyticsBreakdownPanel, + type BreakdownDimension, +} from "@/components/analytics/analytics-breakdown-panel" +import { AnalyticsFilterSidebar } from "@/components/analytics/analytics-filter-sidebar" +import { AnalyticsPagesPanel } from "@/components/analytics/analytics-pages-panel" +import { AnalyticsTrafficChart } from "@/components/analytics/analytics-traffic-chart" +import { countryLabel, languageLabel } from "@/components/analytics/labels" +import { + activeFilterChips, + analyticsFilterSearchFields, + filtersFromSearch, + toggleFilterValue, + type AnalyticsFilterKey, + type AnalyticsFilters, +} from "@/components/analytics/filters" +import { + webAnalyticsBreakdownsResultAtom, + webAnalyticsPagesResultAtom, + webAnalyticsPageviewsResultAtom, + webAnalyticsSummaryResultAtom, + webAnalyticsTimeseriesResultAtom, +} from "@/lib/services/atoms/warehouse-query-atoms" +import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" +import { TimeRangeSearchFields, applyTimeRangeSearch } from "@/components/time-range-picker/search" +import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" +import { TimeRangeHeaderControls } from "@/components/time-range-picker/time-range-header-controls" + +const analyticsSearchSchema = Schema.Struct({ + ...analyticsFilterSearchFields, + ...TimeRangeSearchFields, +}) + +const DEFAULT_PRESET = "7d" +const PAGES_LIMIT = 100 +const BREAKDOWN_LIMIT = 50 + +export const Route = createFileRoute("/analytics/")({ + component: WebAnalyticsPage, + validateSearch: Schema.toStandardSchemaV1(analyticsSearchSchema), +}) + +function WebAnalyticsPage() { + const search = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + + const { startTime, endTime } = useEffectiveTimeRange( + search.startTime, + search.endTime, + search.timePreset ?? DEFAULT_PRESET, + ) + const filters = filtersFromSearch(search) + + const handleTimeChange = ( + range: { startTime?: string; endTime?: string; presetValue?: string }, + options?: { replace?: boolean }, + ) => { + navigate({ + replace: options?.replace, + search: (prev) => ({ ...applyTimeRangeSearch(prev, range) }), + }) + } + + // Empty strings drop out of the URL entirely rather than lingering as `?country=`. + const onFilterChange = (key: AnalyticsFilterKey, value: string | undefined) => { + navigate({ search: (prev) => ({ ...prev, [key]: value === "" ? undefined : value }) }) + } + + const onToggleFilter = (key: AnalyticsFilterKey, value: string) => { + onFilterChange(key, toggleFilterValue(filters[key], value)) + } + + const onClearFilters = () => { + navigate({ + search: { + startTime: search.startTime, + endTime: search.endTime, + timePreset: search.timePreset, + }, + }) + } + + // Retained, not bare: the filters are part of every atom key, so each row click + // instantiates a fresh atom whose first emission is `Initial`. Reading that + // directly would replace the sidebar with a skeleton on every click and reset + // each section's open/search state — same reasoning as the Cloudflare pages. + const breakdownsResult = useRetainedRefreshableResultValue( + webAnalyticsBreakdownsResultAtom({ + data: { startTime, endTime, limitPerDimension: BREAKDOWN_LIMIT, ...filters }, + }), + ) + + const chips = activeFilterChips(filters) + + return ( + + + + + + + + + + + {/* The reciprocal of the Analytics button on Session Replays: this + page aggregates the sessions that page plays back one at a time, + and "who are these people actually" is the next question from + either side. Carries the window across, same as the outbound link. */} + + + + + +
+ 0 ? ( +
+ {chips.map((chip) => ( + + ))} + +
+ ) : undefined + } + /> + +
+
+
+
+
+
+ ) +} + +function AnalyticsContent({ + startTime, + endTime, + filters, + breakdownsResult, + onToggleFilter, +}: { + startTime: string + endTime: string + filters: AnalyticsFilters + breakdownsResult: Result.Result + onToggleFilter: (key: AnalyticsFilterKey, value: string) => void +}) { + const bucketSeconds = chartBucketSeconds(startTime, endTime) + const windowInput = { startTime, endTime, ...filters } + + const summaryResult = useRetainedRefreshableResultValue( + webAnalyticsSummaryResultAtom({ data: windowInput }), + ) + const timeseriesResult = useRetainedRefreshableResultValue( + webAnalyticsTimeseriesResultAtom({ data: { ...windowInput, bucketSeconds } }), + ) + const pageviewsResult = useRetainedRefreshableResultValue( + webAnalyticsPageviewsResultAtom({ data: { ...windowInput, bucketSeconds } }), + ) + const pagesResult = useRetainedRefreshableResultValue( + webAnalyticsPagesResultAtom({ data: { ...windowInput, limit: PAGES_LIMIT } }), + ) + + return ( +
+ + + {Result.builder(timeseriesResult) + .onInitial(() => ) + .onError((error) => ) + .onSuccess((visitors) => ( + views.data) + .orElse(() => [])} + syncId="web-analytics" + /> + )) + .render()} + + {Result.builder(pagesResult) + .onInitial(() => ) + .onError((error) => ) + .onSuccess((pages, result) => ( + onToggleFilter("pagePath", pagePath)} + waiting={result.waiting} + showHost={new Set(pages.data.map((page) => page.host)).size > 1} + /> + )) + .render()} + + {Result.builder(breakdownsResult) + .onInitial(() => ( +
+ + +
+ )) + .onError((error) => ) + .onSuccess((breakdowns, result) => { + const coverageNote = Result.builder(summaryResult) + .onSuccess((summary) => { + if (summary.sessions === 0 || summary.coverage >= 0.98) return undefined + if (summary.identifiedSessions === 0) { + return "No session in this window reports this dimension — it needs an SDK build that sends the analytics fields." + } + return `This dimension covers ${formatPercent(summary.coverage)} of sessions; the rest predate the SDK's analytics fields.` + }) + .orElse(() => undefined) + + const acquisition: ReadonlyArray = [ + { + tab: "Referrers", + rows: breakdowns.referrerHosts, + filterKey: "referrerHost", + noun: "referrer", + nounPlural: "referrers", + coverageDependent: true, + emptyMessage: + "No referrers recorded. Sessions with an empty referrer are excluded rather than bucketed as direct — an empty value also covers internal navigation and Referrer-Policy suppression.", + }, + { + tab: "UTM source", + rows: breakdowns.utmSources, + filterKey: "utmSource", + noun: "source", + nounPlural: "sources", + coverageDependent: true, + }, + { + tab: "Medium", + rows: breakdowns.utmMediums, + filterKey: "utmMedium", + noun: "medium", + nounPlural: "mediums", + coverageDependent: true, + }, + { + tab: "Campaign", + rows: breakdowns.utmCampaigns, + filterKey: "utmCampaign", + noun: "campaign", + nounPlural: "campaigns", + coverageDependent: true, + }, + { + tab: "Entry page", + rows: breakdowns.entryPaths, + filterKey: "pagePath", + noun: "page", + nounPlural: "pages", + coverageDependent: true, + }, + { + tab: "Exit page", + rows: breakdowns.exitPaths, + filterKey: "pagePath", + noun: "page", + nounPlural: "pages", + coverageDependent: true, + }, + ] + + const audience: ReadonlyArray = [ + { + tab: "Countries", + rows: breakdowns.countries, + filterKey: "country", + noun: "country", + nounPlural: "countries", + formatValue: countryLabel, + // Geo is derived at the ingest gateway from Cf-IPCountry and only + // when it is configured to trust that header. Say so, rather than + // letting an empty list read as "nobody visited". + emptyMessage: + "No geo data. Country is resolved at the ingest gateway from the Cloudflare edge header, and only for traffic received after that was enabled — it is never backfilled.", + }, + { + tab: "Devices", + rows: breakdowns.deviceTypes, + filterKey: "deviceType", + noun: "device", + nounPlural: "devices", + }, + { + tab: "Browsers", + rows: breakdowns.browsers, + filterKey: "browserName", + noun: "browser", + nounPlural: "browsers", + }, + { + tab: "OS", + rows: breakdowns.operatingSystems, + filterKey: "osName", + noun: "OS", + nounPlural: "operating systems", + }, + { + tab: "Languages", + rows: breakdowns.languages, + filterKey: "language", + noun: "language", + nounPlural: "languages", + coverageDependent: true, + formatValue: languageLabel, + }, + { + tab: "Sites", + rows: breakdowns.hosts, + filterKey: "host", + noun: "site", + nounPlural: "sites", + coverageDependent: true, + }, + ] + + return ( +
+ filters[key]} + onToggleFilter={onToggleFilter} + waiting={result.waiting} + footnote={coverageNote} + /> + filters[key]} + onToggleFilter={onToggleFilter} + waiting={result.waiting} + footnote={coverageNote} + /> +
+ ) + }) + .render()} +
+ ) +} + +const formatDuration = (ms: number): string => { + if (ms <= 0) return "—" + const seconds = Math.round(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s` +} + +function SummaryRail({ result }: { result: Result.Result }) { + return Result.builder(result) + .onInitial(() => ) + .onError((error) => ) + .onSuccess((summary) => { + // Nothing in this window reports the analytics block, so every + // visitor-level number would be a confident-looking zero. Say "not + // reported" instead — sessions and page views are still real, and the + // panels below still work. + const identified = summary.identifiedSessions > 0 + const partial = identified && summary.coverage < 0.98 + const coverageNote = partial + ? `${formatPercent(summary.coverage)} of sessions report one` + : undefined + + return ( + + + + + + + ) + }) + .render() +} diff --git a/apps/web/src/routes/replays/index.tsx b/apps/web/src/routes/replays/index.tsx index 5d9648f54..4a213c349 100644 --- a/apps/web/src/routes/replays/index.tsx +++ b/apps/web/src/routes/replays/index.tsx @@ -19,6 +19,9 @@ import type { TimeRange } from "@/components/time-range-picker/types" import { QueryErrorState } from "@/components/common/query-error-state" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { ToolbarStat } from "@maple/ui/components/toolbar" +import { Button } from "@maple/ui/components/ui/button" +import { Link } from "@tanstack/react-router" +import { ChartBarHorizontalIcon } from "@/components/icons" const replaysSearchSchema = Schema.Struct({ service: Schema.optional(Schema.String), @@ -127,6 +130,29 @@ function ReplaysPage() { s.status === "active").length} label="live" dot /> + {/* Replays and Web Analytics read the same session data from opposite ends — + one session at a time versus the aggregate — so each is the obvious next + question from the other. The time range travels with the link; arriving + at a different window than the one you were just looking at is what makes + a cross-link feel like it lost your place. */} + (expr: Expr): Expr { return compileFnCall("uniq", expr) } +/** + * `uniqIf(value, condition)` — distinct `value`s among the rows matching + * `condition`. + * + * The conditional counterpart to {@link uniq}, and the one to reach for over + * `countIf` on a `ReplacingMergeTree`: un-merged duplicate rows for the same + * key would inflate a `countIf` but not a `uniqIf` on that key. + */ +export function uniqIf(expr: Expr, cond: Condition): Expr { + return compileFnCall("uniqIf", expr, cond) +} + export function groupUniqArray(expr: Expr): Expr> { return compileFnCall>("groupUniqArray", expr) } diff --git a/lib/clickhouse-builder/src/ch/functions/index.ts b/lib/clickhouse-builder/src/ch/functions/index.ts index 134cc068c..6de112c8d 100644 --- a/lib/clickhouse-builder/src/ch/functions/index.ts +++ b/lib/clickhouse-builder/src/ch/functions/index.ts @@ -13,6 +13,7 @@ export { any_, anyIf, uniq, + uniqIf, sumIf, avgIf, maxIf, @@ -31,6 +32,9 @@ export { left_, length_, lower_, + domain_, + path_, + cutQueryString, replaceOne, extract_, match_, diff --git a/lib/clickhouse-builder/src/ch/functions/string.ts b/lib/clickhouse-builder/src/ch/functions/string.ts index d5bdd698b..1870e1d97 100644 --- a/lib/clickhouse-builder/src/ch/functions/string.ts +++ b/lib/clickhouse-builder/src/ch/functions/string.ts @@ -15,6 +15,21 @@ export const positionCaseInsensitive = defineFn<[Expr, Expr], nu ) export const left_ = defineFn<[Expr, Expr], string>("left") +// --------------------------------------------------------------------------- +// URL functions +// +// ClickHouse parses these without a full URL library: `domain` returns the host +// without scheme, port, or userinfo (and `''` for an unparseable input rather +// than throwing), and `path` returns the pathname only — query string and +// fragment are already excluded, so a path grouped with `path_` carries no +// query-parameter PII. `cutQueryString` is the variant that keeps scheme and +// host, for when the full URL minus its query is wanted. +// --------------------------------------------------------------------------- + +export const domain_ = defineFn<[Expr], string>("domain") +export const path_ = defineFn<[Expr], string>("path") +export const cutQueryString = defineFn<[Expr], string>("cutQueryString") + // --------------------------------------------------------------------------- // Mixed Expr + literal args (compileFnCall wrappers) // --------------------------------------------------------------------------- diff --git a/lib/clickhouse-builder/src/ch/index.ts b/lib/clickhouse-builder/src/ch/index.ts index 904c4c4c9..45530b0a0 100644 --- a/lib/clickhouse-builder/src/ch/index.ts +++ b/lib/clickhouse-builder/src/ch/index.ts @@ -71,6 +71,7 @@ export { any_ as any, anyIf, uniq, + uniqIf, sumIf, avgIf, maxIf, @@ -87,6 +88,9 @@ export { left_ as left, length_ as length, lower_, + domain_, + path_, + cutQueryString, replaceOne, extract_ as extract, match_ as match, diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index 1be54790b..dcb00a205 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -1108,6 +1108,147 @@ export class PodsSummaryResponse extends Schema.Class("Pods stalePods: Schema.Number, }) {} +// --------------------------------------------------------------------------- +// Web Analytics +// +// Product analytics over the browser SDK's session data. Every request shares +// the same filter surface so a facet click narrows all five panels identically; +// `WebAnalyticsFilterFields` is spread rather than nested so the wire shape stays +// flat and the web side can build one filter object per page. +// --------------------------------------------------------------------------- + +const WebAnalyticsFilterFields = { + host: Schema.optional(Schema.String), + pagePath: Schema.optional(Schema.String), + referrerHost: Schema.optional(Schema.String), + country: Schema.optional(Schema.String), + deviceType: Schema.optional(Schema.String), + browserName: Schema.optional(Schema.String), + osName: Schema.optional(Schema.String), + language: Schema.optional(Schema.String), + utmSource: Schema.optional(Schema.String), + utmMedium: Schema.optional(Schema.String), + utmCampaign: Schema.optional(Schema.String), + visitorType: Schema.optional(Schema.Literals(["new", "returning"])), +} as const + +export class WebAnalyticsSummaryRequest extends Schema.Class( + "WebAnalyticsSummaryRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsSummaryResponse extends Schema.Class( + "WebAnalyticsSummaryResponse", +)({ + data: Schema.Struct({ + visitors: Schema.Number, + sessions: Schema.Number, + newSessions: Schema.Number, + bouncedSessions: Schema.Number, + // The coverage numerator: sessions whose SDK build posts the analytics + // block. `identifiedSessions / sessions` is what the page reports so a + // visitor count that covers a fraction of traffic never reads as the whole. + identifiedSessions: Schema.Number, + avgDurationMs: Schema.Number, + }), +}) {} + +export class WebAnalyticsTimeseriesRequest extends Schema.Class( + "WebAnalyticsTimeseriesRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + bucketSeconds: Schema.optional(Schema.Number), + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsTimeseriesResponse extends Schema.Class( + "WebAnalyticsTimeseriesResponse", +)({ + data: Schema.Array( + Schema.Struct({ + bucket: Schema.String, + visitors: Schema.Number, + sessions: Schema.Number, + newSessions: Schema.Number, + }), + ), +}) {} + +export class WebAnalyticsPageviewsRequest extends Schema.Class( + "WebAnalyticsPageviewsRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + bucketSeconds: Schema.optional(Schema.Number), + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsPageviewsResponse extends Schema.Class( + "WebAnalyticsPageviewsResponse", +)({ + data: Schema.Array( + Schema.Struct({ + bucket: Schema.String, + pageViews: Schema.Number, + sessions: Schema.Number, + }), + ), +}) {} + +export class WebAnalyticsPagesRequest extends Schema.Class( + "WebAnalyticsPagesRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + limit: Schema.optional(Schema.Number), + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsPagesResponse extends Schema.Class( + "WebAnalyticsPagesResponse", +)({ + data: Schema.Array( + Schema.Struct({ + host: Schema.String, + pagePath: Schema.String, + pageViews: Schema.Number, + sessions: Schema.Number, + }), + ), +}) {} + +export class WebAnalyticsBreakdownsRequest extends Schema.Class( + "WebAnalyticsBreakdownsRequest", +)({ + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, + limitPerDimension: Schema.optional(Schema.Number), + ...WebAnalyticsFilterFields, +}) {} + +export class WebAnalyticsBreakdownsResponse extends Schema.Class( + "WebAnalyticsBreakdownsResponse", +)({ + data: Schema.Struct({ + referrerHosts: Schema.Array(FacetRow), + countries: Schema.Array(FacetRow), + deviceTypes: Schema.Array(FacetRow), + browsers: Schema.Array(FacetRow), + operatingSystems: Schema.Array(FacetRow), + languages: Schema.Array(FacetRow), + utmSources: Schema.Array(FacetRow), + utmMediums: Schema.Array(FacetRow), + utmCampaigns: Schema.Array(FacetRow), + entryPaths: Schema.Array(FacetRow), + exitPaths: Schema.Array(FacetRow), + hosts: Schema.Array(FacetRow), + }), +}) {} + export class PodFacetsRequest extends Schema.Class("PodFacetsRequest")({ startTime: TinybirdDateTime, endTime: TinybirdDateTime, @@ -1944,6 +2085,41 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") error: queryEngineEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("webAnalyticsSummary", "/web-analytics-summary", { + payload: WebAnalyticsSummaryRequest, + success: WebAnalyticsSummaryResponse, + error: queryEngineEndpointErrors, + }), + ) + .add( + HttpApiEndpoint.post("webAnalyticsTimeseries", "/web-analytics-timeseries", { + payload: WebAnalyticsTimeseriesRequest, + success: WebAnalyticsTimeseriesResponse, + error: queryEngineEndpointErrors, + }), + ) + .add( + HttpApiEndpoint.post("webAnalyticsPageviews", "/web-analytics-pageviews", { + payload: WebAnalyticsPageviewsRequest, + success: WebAnalyticsPageviewsResponse, + error: queryEngineEndpointErrors, + }), + ) + .add( + HttpApiEndpoint.post("webAnalyticsPages", "/web-analytics-pages", { + payload: WebAnalyticsPagesRequest, + success: WebAnalyticsPagesResponse, + error: queryEngineEndpointErrors, + }), + ) + .add( + HttpApiEndpoint.post("webAnalyticsBreakdowns", "/web-analytics-breakdowns", { + payload: WebAnalyticsBreakdownsRequest, + success: WebAnalyticsBreakdownsResponse, + error: queryEngineEndpointErrors, + }), + ) .add( HttpApiEndpoint.post("executeRawSql", "/execute-raw-sql", { payload: RawSqlExecuteRequest, diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 1ab2b3afe..0b5bbc846 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -1963,6 +1963,709 @@ SELECT LIMIT 2 FORMAT JSON +-- builder:web-analytics:webAnalyticsBreakdownsQuery:all-dimensions-filtered [1cbe5313] +SELECT + ReferrerHost AS name, + uniq(SessionId) AS count, + 'referrerHost' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND ReferrerHost != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Country AS name, + uniq(SessionId) AS count, + 'country' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND Country != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + DeviceType AS name, + uniq(SessionId) AS count, + 'deviceType' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND DeviceType != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + BrowserName AS name, + uniq(SessionId) AS count, + 'browserName' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND BrowserName != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + OsName AS name, + uniq(SessionId) AS count, + 'osName' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND OsName != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Language AS name, + uniq(SessionId) AS count, + 'language' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND Language != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmSource AS name, + uniq(SessionId) AS count, + 'utmSource' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND UtmSource != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmMedium AS name, + uniq(SessionId) AS count, + 'utmMedium' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND UtmMedium != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmCampaign AS name, + uniq(SessionId) AS count, + 'utmCampaign' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND VisitorIsNew = 1 + AND UtmCampaign != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + EntryPath AS name, + uniq(SessionId) AS count, + 'entryPath' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND EntryPath != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + ExitPath AS name, + uniq(SessionId) AS count, + 'exitPath' AS facetType + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND ExitPath != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Host AS name, + uniq(SessionId) AS count, + 'host' AS facetType + 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 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 = 'navigation' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + AND Host != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +FORMAT JSON + +-- builder:web-analytics:webAnalyticsBreakdownsQuery:default [7db200be] +SELECT + ReferrerHost AS name, + uniq(SessionId) AS count, + 'referrerHost' AS facetType + 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 ReferrerHost != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Country AS name, + uniq(SessionId) AS count, + 'country' AS facetType + 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 != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + DeviceType AS name, + uniq(SessionId) AS count, + 'deviceType' AS facetType + 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 DeviceType != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + BrowserName AS name, + uniq(SessionId) AS count, + 'browserName' AS facetType + 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 BrowserName != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + OsName AS name, + uniq(SessionId) AS count, + 'osName' AS facetType + 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 OsName != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Language AS name, + uniq(SessionId) AS count, + 'language' AS facetType + 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 Language != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmSource AS name, + uniq(SessionId) AS count, + 'utmSource' AS facetType + 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 UtmSource != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmMedium AS name, + uniq(SessionId) AS count, + 'utmMedium' AS facetType + 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 UtmMedium != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + UtmCampaign AS name, + uniq(SessionId) AS count, + 'utmCampaign' AS facetType + 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 UtmCampaign != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + EntryPath AS name, + uniq(SessionId) AS count, + 'entryPath' AS facetType + 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 EntryPath != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + ExitPath AS name, + uniq(SessionId) AS count, + 'exitPath' AS facetType + 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 ExitPath != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +UNION ALL +SELECT + Host AS name, + uniq(SessionId) AS count, + 'host' AS facetType + 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 Host != '' + GROUP BY name + ORDER BY count DESC + LIMIT 50 +FORMAT JSON + +-- builder:web-analytics:webAnalyticsPagesQuery:default [ee65a1f8] +SELECT + domain(Url) AS host, + path(Url) AS pagePath, + count() AS pageViews, + 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 = 'navigation' + AND domain(Url) != '' + GROUP BY host, pagePath + ORDER BY pageViews DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsPagesQuery:semi-joined [1af66515] +SELECT + domain(Url) AS host, + path(Url) AS pagePath, + count() AS pageViews, + 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 = 'navigation' + 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 domain(Url) != '' + GROUP BY host, pagePath + ORDER BY pageViews DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsPagesQuery:url-filtered [6e64a0b5] +SELECT + domain(Url) AS host, + path(Url) AS pagePath, + count() AS pageViews, + 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND domain(Url) != '' + GROUP BY host, pagePath + ORDER BY pageViews DESC + LIMIT 100 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsPageviewsTimeseriesQuery:default [17056c86] +SELECT + toStartOfInterval(Timestamp, INTERVAL 3600 SECOND) AS bucket, + count() AS pageViews, + 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 = 'navigation' + GROUP BY bucket + ORDER BY bucket ASC + FORMAT JSON + +-- builder:web-analytics:webAnalyticsPageviewsTimeseriesQuery:semi-joined [b52b714d] +SELECT + toStartOfInterval(Timestamp, INTERVAL 3600 SECOND) AS bucket, + count() AS pageViews, + 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 = 'navigation' + 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 ReferrerHost = 't.co' + AND VisitorIsNew = 0 + GROUP BY sessionId) + GROUP BY bucket + ORDER BY bucket ASC + FORMAT JSON + +-- builder:web-analytics:webAnalyticsSummaryQuery:default [10d236a3] +SELECT + uniqIf(VisitorId, VisitorId != '') AS visitors, + uniq(SessionId) AS sessions, + uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, + uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') AS identifiedSessions, + round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs + FROM session_replays + WHERE OrgId = 'org_sql_catalog' + AND StartTime >= '2026-01-01 10:30:00' + AND StartTime <= '2026-01-03 14:15:00' + FORMAT JSON + +-- builder:web-analytics:webAnalyticsSummaryQuery:filtered [5b45919d] +SELECT + uniqIf(VisitorId, VisitorId != '') AS visitors, + uniq(SessionId) AS sessions, + uniqIf(SessionId, VisitorIsNew = 1) AS newSessions, + uniqIf(SessionId, (PageViews <= 1 AND VisitorId != '')) AS bouncedSessions, + uniqIf(SessionId, VisitorId != '') AS identifiedSessions, + round(ifNotFinite(avgIf(assumeNotNull(DurationMs), DurationMs > 0), 0)) AS avgDurationMs + 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 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 = 'navigation' + AND domain(Url) = 'maple.dev' + AND path(Url) = '/pricing' + GROUP BY sessionId) + AND ReferrerHost = 't.co' + AND Country = 'DE' + AND DeviceType = 'desktop' + AND BrowserName = 'Chrome' + AND OsName = 'macOS' + AND Language = 'en-US' + AND UtmSource = 'twitter' + AND UtmMedium = 'social' + AND UtmCampaign = 'launch' + AND VisitorIsNew = 1 + FORMAT JSON + +-- builder:web-analytics:webAnalyticsTimeseriesQuery:default [ff514e44] +SELECT + toStartOfInterval(StartTime, INTERVAL 3600 SECOND) AS bucket, + uniqIf(VisitorId, VisitorId != '') AS visitors, + uniq(SessionId) AS sessions, + uniqIf(SessionId, VisitorIsNew = 1) AS newSessions + FROM session_replays + WHERE OrgId = 'org_sql_catalog' + AND StartTime >= '2026-01-01 10:30:00' + AND StartTime <= '2026-01-03 14:15:00' + GROUP BY bucket + ORDER BY bucket ASC + FORMAT JSON + -- pipe:custom_traces_breakdown:by-attribute:baseline [a82b913f] SELECT SpanAttributes['http.route'] AS name, diff --git a/packages/query-engine/src/ch/builder-fixtures.ts b/packages/query-engine/src/ch/builder-fixtures.ts index 1266d32bd..71dc0f2ac 100644 --- a/packages/query-engine/src/ch/builder-fixtures.ts +++ b/packages/query-engine/src/ch/builder-fixtures.ts @@ -192,6 +192,124 @@ export const builderFixtures: ReadonlyArray = [ }), }, + // ----- web-analytics (routes/v1/query-engine.http.ts, via registry/queries.ts) ----- + // + // Two fixtures per page-view builder: unfiltered, and with a + // session_replays-only filter set. The second is the one that matters — it + // forces the `SessionId IN (SELECT …)` semi-join branch, which is a whole + // second SQL shape that no unfiltered fixture reaches. + { + module: "web-analytics", + name: "webAnalyticsSummaryQuery", + label: "default", + compile: () => CH.compile(CH.webAnalyticsSummaryQuery({}), window), + }, + { + module: "web-analytics", + name: "webAnalyticsSummaryQuery", + label: "filtered", + compile: () => + CH.compile( + CH.webAnalyticsSummaryQuery({ + host: "maple.dev", + pagePath: "/pricing", + referrerHost: "t.co", + country: "DE", + deviceType: "desktop", + browserName: "Chrome", + osName: "macOS", + language: "en-US", + utmSource: "twitter", + utmMedium: "social", + utmCampaign: "launch", + visitorType: "new", + }), + window, + ), + }, + { + module: "web-analytics", + name: "webAnalyticsTimeseriesQuery", + label: "default", + compile: () => CH.compile(CH.webAnalyticsTimeseriesQuery({ bucketSeconds: 3600 }), window), + }, + { + module: "web-analytics", + name: "webAnalyticsPageviewsTimeseriesQuery", + label: "default", + compile: () => + CH.compile(CH.webAnalyticsPageviewsTimeseriesQuery({ bucketSeconds: 3600 }), window), + }, + { + // Forces the semi-join: `referrerHost` is a session_replays-only dimension, + // so session_events has to narrow through a subquery to honour it. + module: "web-analytics", + name: "webAnalyticsPageviewsTimeseriesQuery", + label: "semi-joined", + compile: () => + CH.compile( + CH.webAnalyticsPageviewsTimeseriesQuery({ + bucketSeconds: 3600, + referrerHost: "t.co", + visitorType: "returning", + }), + window, + ), + }, + { + module: "web-analytics", + name: "webAnalyticsPagesQuery", + label: "default", + compile: () => CH.compile(CH.webAnalyticsPagesQuery({ limit: 100 }), window), + }, + { + // host/pagePath filter directly off Url — deliberately NOT through the + // semi-join, so the 82% of sessions with no analytics block still count. + module: "web-analytics", + name: "webAnalyticsPagesQuery", + label: "url-filtered", + compile: () => + CH.compile(CH.webAnalyticsPagesQuery({ limit: 100, host: "maple.dev" }), window), + }, + { + module: "web-analytics", + name: "webAnalyticsPagesQuery", + label: "semi-joined", + compile: () => + CH.compile(CH.webAnalyticsPagesQuery({ limit: 100, country: "DE" }), window), + }, + { + module: "web-analytics", + name: "webAnalyticsBreakdownsQuery", + label: "default", + compile: () => CH.compileUnion(CH.webAnalyticsBreakdownsQuery({}), window), + }, + { + // Every dimension selected at once: each branch must exclude its own filter, + // so this is the fixture that would catch a branch that forgot to. + module: "web-analytics", + name: "webAnalyticsBreakdownsQuery", + label: "all-dimensions-filtered", + compile: () => + CH.compileUnion( + CH.webAnalyticsBreakdownsQuery({ + host: "maple.dev", + pagePath: "/pricing", + referrerHost: "t.co", + country: "DE", + deviceType: "desktop", + browserName: "Chrome", + osName: "macOS", + language: "en-US", + utmSource: "twitter", + utmMedium: "social", + utmCampaign: "launch", + visitorType: "new", + }), + window, + ), + }, + // ----- errors builders reached only via direct calls (ErrorsService, v2 telemetry, observability) ----- { // telemetry.http.ts v2GetSpan / observability/span-detail.ts diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index 5036c6bb2..c2695800f 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -154,6 +154,26 @@ export { type SessionActivityOutput, } from "./queries/session-events" +// Queries — Web Analytics (product analytics over the browser SDK's session data) +export { + webAnalyticsSummaryQuery, + webAnalyticsTimeseriesQuery, + webAnalyticsPageviewsTimeseriesQuery, + webAnalyticsPagesQuery, + webAnalyticsBreakdownsQuery, + type WebAnalyticsFilters, + type WebAnalyticsFacetKey, + type WebAnalyticsSummaryOutput, + type WebAnalyticsTimeseriesOpts, + type WebAnalyticsTimeseriesOutput, + type WebAnalyticsPageviewsTimeseriesOpts, + type WebAnalyticsPageviewsTimeseriesOutput, + type WebAnalyticsPagesOpts, + type WebAnalyticsPagesOutput, + type WebAnalyticsBreakdownsOpts, + type WebAnalyticsBreakdownsOutput, +} from "./queries/web-analytics" + // Queries — Services export { serviceOverviewQuery, diff --git a/packages/query-engine/src/ch/queries/web-analytics.ts b/packages/query-engine/src/ch/queries/web-analytics.ts new file mode 100644 index 000000000..c548eff0b --- /dev/null +++ b/packages/query-engine/src/ch/queries/web-analytics.ts @@ -0,0 +1,508 @@ +// --------------------------------------------------------------------------- +// Web Analytics Queries +// +// Product analytics over the browser SDK's session data: unique visitors, page +// views, top pages, acquisition (referrer / UTM), and audience breakdowns +// (country / device / browser / OS / language). +// +// ## Two tables, and three coverage tiers +// +// These queries deliberately read from *both* session tables, because neither +// one alone can answer the page. What matters is that the columns fall into +// three tiers of coverage, and conflating them produces numbers that contradict +// each other on screen: +// +// 1. **`session_events` with `Type = 'navigation'`** — one row per page view, +// for every session across every SDK build. Knows only SessionId, Timestamp +// and Url. This is the widest-coverage source there is, and where page views +// and top pages come from. +// 2. **`session_replays` base columns** — `BrowserName`, `OsName`, +// `DeviceType`, `Country`, `DurationMs`. Predate migration 0011 and are +// populated for essentially every session. (`Country` has its own gap: it is +// derived at the ingest gateway from `Cf-IPCountry` and only when the +// gateway is configured to trust that header, so it is `''` for traffic +// received before that was enabled and is never backfilled.) +// 3. **`session_replays` analytics block** (migration 0011) — `VisitorId`, +// `VisitorIsNew`, `Referrer`, `ReferrerHost`, `Utm*`, `Host`, `EntryPath`, +// `ExitPath`, `Language`, and in practice `PageViews`. Only sessions from an +// SDK build that posts the block populate these. Measured against production +// at the time of writing: one org of seven, ~18% of sessions. +// +// So a page-view count and a visitor count on the same screen describe different +// populations, and the UI is expected to say which — `webAnalyticsSummaryQuery` +// returns `identifiedSessions` alongside `sessions` precisely so the page can +// report the covered share instead of presenting a partial count as the whole. +// Tier 2 needs no such caveat, and attaching one to it is its own bug. +// +// ## ReplacingMergeTree counting +// +// The SDK writes a session twice: Version=1 at session start, Version=2 at +// session end. Reads can see both rows before a background merge collapses +// them, so **every count over `session_replays` here is `uniq`/`uniqIf` on +// SessionId, never `count`/`countIf`** — the same rule +// `sessionReplaysFacetsQuery` follows. `sum(PageViews)` is the one exception and +// is *not* used for the page-view metric for exactly this reason; page views +// come from the append-only `session_events` instead. +// +// Filters only touch version-invariant columns. Every analytics column +// qualifies (the SDK writes them on both the v1 and v2 row — see the invariant +// documented in packages/browser-session/src/meta-row.ts), so they are safe in +// WHERE; the DSL has no HAVING. +// --------------------------------------------------------------------------- + +import * as CH from "@maple-dev/clickhouse-builder/expr" +import { param, from, inSubquery, unionAll, compileFnCall } from "@maple-dev/clickhouse-builder" +import type { ColumnAccessor, CHQuery, CHUnionQuery } from "@maple-dev/clickhouse-builder" +import { SessionReplays, SessionEvents } from "../tables" +import type { FacetOutput } from "./query-helpers" + +/** The `session_events.Type` value the SDK emits once per page view. */ +const NAVIGATION = "navigation" + +// 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. +function assumeNotNull(value: CH.Expr): CH.Expr { + return compileFnCall("assumeNotNull", value) +} + +// ifNotFinite(x, fallback) — avg() over an empty set yields nan, which would +// then decode as null through a numeric row schema. +function ifNotFinite(value: CH.Expr, fallback: number): CH.Expr { + return compileFnCall("ifNotFinite", value, CH.lit(fallback)) +} + +// --------------------------------------------------------------------------- +// Shared filter surface +// --------------------------------------------------------------------------- + +/** + * The URL-driven filter surface, shared by every query on the page so a facet + * click narrows all of them identically. + * + * `host` / `pagePath` are the two that `session_events` can serve directly off + * `Url`, and they reach `session_replays` through + * {@link navigationSessionsSubquery}. The rest are `session_replays` columns and + * reach `session_events` through {@link matchingSessionsSubquery} — the same + * semi-join in the other direction. + */ +export interface WebAnalyticsFilters { + readonly host?: string + readonly pagePath?: string + readonly referrerHost?: string + readonly country?: string + readonly deviceType?: string + readonly browserName?: string + readonly osName?: string + readonly language?: string + readonly utmSource?: string + readonly utmMedium?: string + readonly utmCampaign?: string + /** `new` keeps first-ever sessions for a visitor, `returning` the rest. */ + readonly visitorType?: "new" | "returning" +} + +/** Which `session_replays` dimensions a facet branch can exclude from its own WHERE. */ +export type WebAnalyticsFacetKey = + | "referrerHost" + | "country" + | "deviceType" + | "browserName" + | "osName" + | "language" + | "utmSource" + | "utmMedium" + | "utmCampaign" + | "entryPath" + | "exitPath" + | "host" + +type ReplaysAccessor = ColumnAccessor + +/** + * `SELECT SessionId FROM session_events WHERE ` — + * which sessions actually viewed the filtered page or site. + * + * This is how the `host` and `pagePath` filters reach `session_replays`, and it + * is a semi-join rather than an `EntryPath = …` predicate for a reason. + * `EntryPath`, `ExitPath` and `Host` are all part of the migration-0011 + * analytics block, so on an org whose SDK build predates it they are `''` for + * every row — filtering them made the KPI rail report **0 sessions** directly + * beside a top-pages panel showing 27k page views for the very page being + * filtered on. `session_events` has no such gap, and "sessions that viewed this + * page" is the better definition anyway: entering or exiting on a page is a + * narrower thing than visiting it. + * + * `only` restricts which of the two filters the subquery honours, so a facet + * branch can exclude its own dimension (see `replaysWhere`). + */ +function navigationSessionsSubquery(filters: WebAnalyticsFilters, only?: "host" | "pagePath") { + return from(SessionEvents) + .select(($) => ({ sessionId: $.SessionId })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTime("startTime")), + $.Timestamp.lte(param.dateTime("endTime")), + $.Type.eq(NAVIGATION), + 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)), + ]) + .groupBy("sessionId") +} + +/** + * WHERE conditions for `session_replays`. + * + * `exclude` drops one dimension's own equality filter so its facet branch + * doesn't collapse to the single selected value — the sidebar has to keep + * offering the alternatives. + */ +function replaysWhere( + $: ReplaysAccessor, + filters: WebAnalyticsFilters, + exclude?: WebAnalyticsFacetKey, +): Array { + // `host` and `pagePath` both narrow through one navigation semi-join rather + // than two, so a page filter and a site filter compose into "sessions that + // viewed this path on this host" instead of two independent session sets. + // A facet branch drops its own dimension from the subquery; when that leaves + // nothing to match on, the subquery isn't built at all. + const wantsHost = filters.host !== undefined && exclude !== "host" + const wantsPath = filters.pagePath !== undefined && exclude !== "entryPath" && exclude !== "exitPath" + const navigationFilter = + wantsHost || wantsPath + ? inSubquery( + $.SessionId, + navigationSessionsSubquery( + filters, + wantsHost && wantsPath ? undefined : wantsHost ? "host" : "pagePath", + ), + ) + : undefined + + return [ + $.OrgId.eq(param.string("orgId")), + $.StartTime.gte(param.dateTime("startTime")), + $.StartTime.lte(param.dateTime("endTime")), + navigationFilter, + exclude === "referrerHost" + ? undefined + : CH.when(filters.referrerHost, (v: string) => $.ReferrerHost.eq(v)), + exclude === "country" ? undefined : CH.when(filters.country, (v: string) => $.Country.eq(v)), + exclude === "deviceType" ? undefined : CH.when(filters.deviceType, (v: string) => $.DeviceType.eq(v)), + exclude === "browserName" + ? undefined + : CH.when(filters.browserName, (v: string) => $.BrowserName.eq(v)), + exclude === "osName" ? undefined : CH.when(filters.osName, (v: string) => $.OsName.eq(v)), + exclude === "language" ? undefined : CH.when(filters.language, (v: string) => $.Language.eq(v)), + exclude === "utmSource" ? undefined : CH.when(filters.utmSource, (v: string) => $.UtmSource.eq(v)), + exclude === "utmMedium" ? undefined : CH.when(filters.utmMedium, (v: string) => $.UtmMedium.eq(v)), + exclude === "utmCampaign" + ? undefined + : CH.when(filters.utmCampaign, (v: string) => $.UtmCampaign.eq(v)), + CH.when(filters.visitorType, (v: "new" | "returning") => + v === "new" ? $.VisitorIsNew.eq(1) : $.VisitorIsNew.eq(0), + ), + ] +} + +/** True when any filter can only be evaluated against `session_replays`. */ +function needsSessionSemiJoin(filters: WebAnalyticsFilters): boolean { + return Boolean( + filters.referrerHost || + filters.country || + filters.deviceType || + filters.browserName || + filters.osName || + filters.language || + filters.utmSource || + filters.utmMedium || + filters.utmCampaign || + filters.visitorType, + ) +} + +/** + * `SELECT SessionId FROM session_replays WHERE ` — the + * semi-join that carries a `session_replays` dimension filter over to + * `session_events`, mirroring how `sessionReplaysListQuery` semi-joins + * `sessionEventMatchQuery` in the other direction. + * + * `host` and `pagePath` are deliberately left out, which also keeps this from + * 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. + */ +function matchingSessionsSubquery(filters: WebAnalyticsFilters) { + return from(SessionReplays) + .select(($) => ({ sessionId: $.SessionId })) + .where(($) => replaysWhere($, { ...filters, host: undefined, pagePath: undefined })) + .groupBy("sessionId") +} + +/** + * WHERE conditions for the page-view queries over `session_events`. + * + * `Type = 'navigation'` is served by the `idx_type` set(16) skip index, and the + * `Timestamp` bounds prune `PARTITION BY toDate(Timestamp)` — `Timestamp` sits + * third in the sorting key, so partition pruning is what keeps this cheap. + */ +function navigationWhere( + $: ColumnAccessor, + filters: WebAnalyticsFilters, +): Array { + return [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTime("startTime")), + $.Timestamp.lte(param.dateTime("endTime")), + $.Type.eq(NAVIGATION), + CH.when(filters.host, (v: string) => CH.domain_($.Url).eq(v)), + CH.when(filters.pagePath, (v: string) => CH.path_($.Url).eq(v)), + needsSessionSemiJoin(filters) + ? inSubquery($.SessionId, matchingSessionsSubquery(filters)) + : undefined, + ] +} + +// --------------------------------------------------------------------------- +// Summary KPIs +// --------------------------------------------------------------------------- + +export interface WebAnalyticsSummaryOutput { + /** Distinct persistent browser ids. 0 when no session carries the analytics block. */ + readonly visitors: number + /** Distinct sessions — the denominator the UI compares `visitors` against. */ + readonly sessions: number + /** Sessions whose visitor id had never been seen before, per the client's assertion. */ + readonly newSessions: number + /** + * Sessions with at most one page view, **among identified sessions only** — + * divide by `identifiedSessions`, not `sessions`. See the query for why. + */ + readonly bouncedSessions: number + /** Sessions carrying a VisitorId, i.e. the analytics-block coverage numerator. */ + readonly identifiedSessions: number + /** Mean wall-clock session duration in ms over sessions that ended. */ + readonly avgDurationMs: number +} + +/** + * One row of headline numbers over `session_replays`. + * + * Page views are absent on purpose — they come from + * {@link webAnalyticsPageviewsTimeseriesQuery}, whose coverage is far wider than + * this table's `PageViews` column. Summing `PageViews` here would also + * double-count across the v1/v2 rows of an un-merged session. + * + * `bouncedSessions` is gated on `VisitorId != ''` — the same predicate as + * `identifiedSessions`, and load-bearing rather than decorative. `PageViews` is + * part of the migration-0011 analytics block, so a session from an older SDK + * build reports `PageViews = 0`; counting those as bounces reported a **100% + * bounce rate** for an org whose top-pages panel, read from `session_events` on + * the very same page, plainly showed multi-page sessions. Bounce is only + * measurable over the population that reports page views, so the numerator and + * the denominator (`identifiedSessions`) are both confined to it. + * + * Within that population `PageViews <= 1` rather than `= 1`, so a session whose + * only row is still the v1 start row counts as a bounce instead of vanishing + * from both sides of the ratio. + */ +export function webAnalyticsSummaryQuery( + filters: WebAnalyticsFilters = {}, +): CHQuery { + return from(SessionReplays) + .select(($) => ({ + visitors: CH.uniqIf($.VisitorId, $.VisitorId.neq("")), + sessions: CH.uniq($.SessionId), + newSessions: CH.uniqIf($.SessionId, $.VisitorIsNew.eq(1)), + bouncedSessions: CH.uniqIf($.SessionId, $.PageViews.lte(1).and($.VisitorId.neq(""))), + identifiedSessions: CH.uniqIf($.SessionId, $.VisitorId.neq("")), + // avgIf over the ended rows only: the v1 row's DurationMs is NULL, and + // including it would average NULLs into the result. + avgDurationMs: CH.round_( + ifNotFinite(CH.avgIf(assumeNotNull($.DurationMs), $.DurationMs.gt(0)), 0), + ), + })) + .where(($) => replaysWhere($, filters)) + .format("JSON") +} + +// --------------------------------------------------------------------------- +// Visitor timeseries +// --------------------------------------------------------------------------- + +export interface WebAnalyticsTimeseriesOpts extends WebAnalyticsFilters { + readonly bucketSeconds?: number +} + +export interface WebAnalyticsTimeseriesOutput { + readonly bucket: string + readonly visitors: number + readonly sessions: number + readonly newSessions: number +} + +/** + * Visitors / sessions / new sessions per bucket, from `session_replays`. + * + * Bucketed on `StartTime`, so a session lands in the bucket it began in rather + * than being smeared across the buckets it spanned. That matches how every + * comparable product counts a session and keeps the branch a flat aggregate. + */ +export function webAnalyticsTimeseriesQuery( + opts: WebAnalyticsTimeseriesOpts = {}, +): CHQuery { + const bucketSeconds = opts.bucketSeconds ?? 3600 + return from(SessionReplays) + .select(($) => ({ + bucket: CH.toStartOfInterval($.StartTime, bucketSeconds), + visitors: CH.uniqIf($.VisitorId, $.VisitorId.neq("")), + sessions: CH.uniq($.SessionId), + newSessions: CH.uniqIf($.SessionId, $.VisitorIsNew.eq(1)), + })) + .where(($) => replaysWhere($, opts)) + .groupBy("bucket") + .orderBy(["bucket", "asc"]) + .format("JSON") +} + +// --------------------------------------------------------------------------- +// Page-view timeseries +// --------------------------------------------------------------------------- + +export interface WebAnalyticsPageviewsTimeseriesOpts extends WebAnalyticsFilters { + readonly bucketSeconds?: number +} + +export interface WebAnalyticsPageviewsTimeseriesOutput { + readonly bucket: string + readonly pageViews: number + readonly sessions: number +} + +/** + * Page views per bucket from `session_events` navigation rows — a plain + * `count()`, since that table is an append-only MergeTree with no duplicate + * rows to guard against. + * + * Its own query rather than a branch of {@link webAnalyticsTimeseriesQuery} + * because it reads a different table with a different time column and a + * narrower filter surface. + */ +export function webAnalyticsPageviewsTimeseriesQuery( + opts: WebAnalyticsPageviewsTimeseriesOpts = {}, +): CHQuery { + const bucketSeconds = opts.bucketSeconds ?? 3600 + return from(SessionEvents) + .select(($) => ({ + bucket: CH.toStartOfInterval($.Timestamp, bucketSeconds), + pageViews: CH.count(), + sessions: CH.uniq($.SessionId), + })) + .where(($) => navigationWhere($, opts)) + .groupBy("bucket") + .orderBy(["bucket", "asc"]) + .format("JSON") +} + +// --------------------------------------------------------------------------- +// Top pages +// --------------------------------------------------------------------------- + +export interface WebAnalyticsPagesOpts extends WebAnalyticsFilters { + readonly limit?: number +} + +export interface WebAnalyticsPagesOutput { + readonly host: string + readonly pagePath: string + readonly pageViews: number + readonly sessions: number +} + +/** + * Most-viewed pages, grouped by `domain(Url)` + `path(Url)`. + * + * `path()` returns the pathname only — query string and fragment are already + * excluded — so grouping on it cannot leak query-parameter PII into a + * dimension list. Rows whose Url failed to parse (`domain` = '') are dropped + * rather than collapsed into a blank row. + */ +export function webAnalyticsPagesQuery( + opts: WebAnalyticsPagesOpts = {}, +): CHQuery { + return from(SessionEvents) + .select(($) => ({ + host: CH.domain_($.Url), + pagePath: CH.path_($.Url), + pageViews: CH.count(), + sessions: CH.uniq($.SessionId), + })) + .where(($) => [...navigationWhere($, opts), CH.domain_($.Url).neq("")]) + .groupBy("host", "pagePath") + .orderBy(["pageViews", "desc"]) + .limit(opts.limit ?? 100) + .format("JSON") +} + +// --------------------------------------------------------------------------- +// Dimension breakdowns (UNION ALL fan-out) +// --------------------------------------------------------------------------- + +export type WebAnalyticsBreakdownsOpts = WebAnalyticsFilters & { + /** Rows per dimension. Defaults to 50 — enough for a sidebar, small enough to stay cheap. */ + readonly limitPerDimension?: number +} + +export type WebAnalyticsBreakdownsOutput = FacetOutput + +/** + * Every audience and acquisition dimension in one round trip, shaped + * `{ name, count, facetType }` like the other facet queries so the web side can + * decode it with the shared `extractFacets` helper. + * + * Two rules per branch, both inherited from `sessionReplaysFacetsQuery`: + * `.neq("")` drops sessions that never populated the column (so a dimension + * nobody sends renders as empty rather than as one giant blank row), and the + * branch excludes its own filter so selecting a value leaves the alternatives + * visible. + * + * `ReferrerHost = ''` is dropped here along with the rest. That is not a lost + * "direct traffic" bucket — per the schema comment it also covers internal + * navigation and `Referrer-Policy`-suppressed referrers, so it is not a + * meaningful row to show. UTM is the reliable acquisition signal. + */ +export function webAnalyticsBreakdownsQuery( + opts: WebAnalyticsBreakdownsOpts = {}, +): CHUnionQuery { + const limit = opts.limitPerDimension ?? 50 + + const makeFacet = (facetType: WebAnalyticsFacetKey, column: ($: ReplaysAccessor) => CH.Expr) => + from(SessionReplays) + .select(($) => ({ + name: column($), + // uniq(SessionId), not count(): the v1/v2 rows of an un-merged session + // would otherwise weight it twice in every dimension. + count: CH.uniq($.SessionId), + facetType: CH.lit(facetType), + })) + .where(($) => [...replaysWhere($, opts, facetType), column($).neq("")]) + .groupBy("name") + .orderBy(["count", "desc"]) + .limit(limit) + + return unionAll( + makeFacet("referrerHost", ($) => $.ReferrerHost), + makeFacet("country", ($) => $.Country), + makeFacet("deviceType", ($) => $.DeviceType), + makeFacet("browserName", ($) => $.BrowserName), + makeFacet("osName", ($) => $.OsName), + makeFacet("language", ($) => $.Language), + makeFacet("utmSource", ($) => $.UtmSource), + makeFacet("utmMedium", ($) => $.UtmMedium), + makeFacet("utmCampaign", ($) => $.UtmCampaign), + makeFacet("entryPath", ($) => $.EntryPath), + makeFacet("exitPath", ($) => $.ExitPath), + makeFacet("host", ($) => $.Host), + ).format("JSON") +} diff --git a/packages/query-engine/src/registry/queries.ts b/packages/query-engine/src/registry/queries.ts index e38be97fe..459d32fe4 100644 --- a/packages/query-engine/src/registry/queries.ts +++ b/packages/query-engine/src/registry/queries.ts @@ -32,6 +32,11 @@ import type { ServiceHealthSnapshotRequest, ServiceOverviewRequest, WorkloadDetailSummaryRequest, + WebAnalyticsSummaryRequest, + WebAnalyticsTimeseriesRequest, + WebAnalyticsPageviewsRequest, + WebAnalyticsPagesRequest, + WebAnalyticsBreakdownsRequest, } from "@maple/domain/http" import { Match } from "effect" import { attributeIndexMode, logBodySearchMode } from "../capabilities" @@ -661,6 +666,112 @@ export const serviceDbTopQueries = defineQuery({ CH.serviceDbTopQueriesSQL(dbQueryParams(payload, orgId)), }) +// --- Web analytics -------------------------------------------------------- +// +// Five queries behind one page, so they share a filter surface. `webAnalytics*` +// payloads carry the filters flat; `webAnalyticsFilters` picks them off so each +// entry below stays a one-line pass-through and the five can't drift apart. +// +// All 15s: this is a live dashboard people watch during a launch, and the +// breakdown fan-out is over a 30-day-TTL table small enough that 60s buys +// nothing but a page that looks stuck after a filter click. + +const webAnalyticsFilters = (payload: { + readonly host?: string + readonly pagePath?: string + readonly referrerHost?: string + readonly country?: string + readonly deviceType?: string + readonly browserName?: string + readonly osName?: string + readonly language?: string + readonly utmSource?: string + readonly utmMedium?: string + readonly utmCampaign?: string + readonly visitorType?: "new" | "returning" +}): CH.WebAnalyticsFilters => ({ + host: payload.host, + pagePath: payload.pagePath, + referrerHost: payload.referrerHost, + country: payload.country, + deviceType: payload.deviceType, + browserName: payload.browserName, + osName: payload.osName, + language: payload.language, + utmSource: payload.utmSource, + utmMedium: payload.utmMedium, + utmCampaign: payload.utmCampaign, + visitorType: payload.visitorType, +}) + +export const webAnalyticsSummary = defineQuery({ + id: "webAnalyticsSummary", + profile: "aggregation", + cache: 15, + compile: (payload: WebAnalyticsSummaryRequest, orgId: string) => + CH.compile(CH.webAnalyticsSummaryQuery(webAnalyticsFilters(payload)), { + orgId, + startTime: payload.startTime, + endTime: payload.endTime, + }), +}) + +export const webAnalyticsTimeseries = defineQuery({ + id: "webAnalyticsTimeseries", + profile: "aggregation", + cache: 15, + compile: (payload: WebAnalyticsTimeseriesRequest, orgId: string) => + CH.compile( + CH.webAnalyticsTimeseriesQuery({ + ...webAnalyticsFilters(payload), + bucketSeconds: payload.bucketSeconds, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), +}) + +export const webAnalyticsPageviews = defineQuery({ + id: "webAnalyticsPageviews", + profile: "aggregation", + cache: 15, + compile: (payload: WebAnalyticsPageviewsRequest, orgId: string) => + CH.compile( + CH.webAnalyticsPageviewsTimeseriesQuery({ + ...webAnalyticsFilters(payload), + bucketSeconds: payload.bucketSeconds, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), +}) + +export const webAnalyticsPages = defineQuery({ + id: "webAnalyticsPages", + profile: "aggregation", + cache: 15, + compile: (payload: WebAnalyticsPagesRequest, orgId: string) => + CH.compile( + CH.webAnalyticsPagesQuery({ ...webAnalyticsFilters(payload), limit: payload.limit }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), +}) + +export const webAnalyticsBreakdowns = defineQuery({ + id: "webAnalyticsBreakdowns", + profile: "aggregation", + // Same read-thread cap as the other UNION fan-outs: 12 branches over one + // table, and unbounded threads buy latency at the cost of memory spikes. + settings: { maxThreads: 4 }, + cache: 15, + compile: (payload: WebAnalyticsBreakdownsRequest, orgId: string) => + CH.compileUnion( + CH.webAnalyticsBreakdownsQuery({ + ...webAnalyticsFilters(payload), + limitPerDimension: payload.limitPerDimension, + }), + { orgId, startTime: payload.startTime, endTime: payload.endTime }, + ), +}) + // --- Facet queries (UNION of per-dimension branches) ---------------------- export const podFacets = defineQuery({ diff --git a/packages/query-engine/src/sql-catalog.test.ts b/packages/query-engine/src/sql-catalog.test.ts index 987906e1b..fc1ee446e 100644 --- a/packages/query-engine/src/sql-catalog.test.ts +++ b/packages/query-engine/src/sql-catalog.test.ts @@ -28,6 +28,7 @@ import * as serviceOperationQueries from "./ch/queries/service-operations" import * as serviceQueries from "./ch/queries/services" import * as sessionEventQueries from "./ch/queries/session-events" import * as sessionReplayQueries from "./ch/queries/session-replays" +import * as webAnalyticsQueries from "./ch/queries/web-analytics" import * as topOperationQueries from "./ch/queries/top-operations" import * as traceQueries from "./ch/queries/traces" @@ -164,6 +165,7 @@ const QUERY_MODULES: Record> = { "session-events": sessionEventQueries, "session-replays": sessionReplayQueries, "top-operations": topOperationQueries, + "web-analytics": webAnalyticsQueries, traces: traceQueries, }