From 07c66242b29afe4a95d59447a34e27de35258403 Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Mon, 3 Aug 2026 18:42:14 +0530 Subject: [PATCH 1/3] enable ui telemetry for all events --- .../[organization]/[project]/+layout.svelte | 20 ++++++++++++++++++- .../src/metrics/BehaviourEventHandler.ts | 7 +++++++ .../metrics/service/BehaviourEventFactory.ts | 18 +++++++++++++++++ .../metrics/service/BehaviourEventTypes.ts | 4 ++++ .../metrics/service/MetricsEventFactory.ts | 1 + .../src/metrics/service/MetricsTypes.ts | 4 ++++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 6166d3af3296..12fd2f3ad529 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -29,6 +29,7 @@ getAdminServiceListDeploymentsQueryKey, } from "@rilldata/web-admin/client"; import { + getScreenNameFromPage, isEditPage, isProjectInvitePage, isProjectPage, @@ -51,7 +52,10 @@ import { viewAsUserStore } from "@rilldata/web-admin/features/view-as-user/viewAsUserStore"; import ErrorPage from "@rilldata/web-common/components/ErrorPage.svelte"; import { themeControl } from "@rilldata/web-common/features/themes/theme-control"; - import { metricsService } from "@rilldata/web-common/metrics/initMetrics"; + import { + behaviourEvent, + metricsService, + } from "@rilldata/web-common/metrics/initMetrics"; import RuntimeProvider from "@rilldata/web-common/runtime-client/v2/RuntimeProvider.svelte"; import type { HTTPError } from "@rilldata/web-common/lib/errors"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; @@ -246,6 +250,20 @@ }); } }); + + // Fire a page view for every dashboard, canvas and project page the user opens. + // This runs off `page` rather than `afterNavigate` because the first page view has to wait for the + // current user query above to resolve, which happens after the initial navigation has completed. + // Only the path is tracked: the query string changes on every filter and time range tweak, which + // would turn page views into an interaction firehose. + let lastTrackedPath: string | undefined; + $effect(() => { + const path = page.url.pathname; + // Events are dropped until loadCloudFields has run, so wait on the same inputs it needs. + if (!project || !$user.data?.user?.id || path === lastTrackedPath) return; + lastTrackedPath = path; + void behaviourEvent?.firePageViewEvent(getScreenNameFromPage(page)); + }); {#if error} diff --git a/web-common/src/metrics/BehaviourEventHandler.ts b/web-common/src/metrics/BehaviourEventHandler.ts index 923c2dee7f6b..4adc69ad7ccb 100644 --- a/web-common/src/metrics/BehaviourEventHandler.ts +++ b/web-common/src/metrics/BehaviourEventHandler.ts @@ -25,6 +25,13 @@ export class BehaviourEventHandler { this.commonUserMetrics = commonUserMetrics; } + public firePageViewEvent(screen_name: MetricsEventScreenName) { + return this.metricsService.dispatch("pageViewEvent", [ + this.commonUserMetrics, + screen_name, + ]); + } + public fireNavigationEvent( entity_name: string, medium: BehaviourEventMedium, diff --git a/web-common/src/metrics/service/BehaviourEventFactory.ts b/web-common/src/metrics/service/BehaviourEventFactory.ts index 9ac5dd60c521..5b5e5207b046 100644 --- a/web-common/src/metrics/service/BehaviourEventFactory.ts +++ b/web-common/src/metrics/service/BehaviourEventFactory.ts @@ -15,6 +15,24 @@ import { import type { SourceConnectionType, SourceFileType } from "./SourceEventTypes"; export class BehaviourEventFactory extends MetricsEventFactory { + // The page the user landed on is carried by the base event's `page_url`, so the screen name is + // all this needs on top of it. + public pageViewEvent( + commonFields: CommonFields, + commonUserFields: CommonUserFields, + screen_name: MetricsEventScreenName, + ): BehaviourEvent { + const event = this.getBaseMetricsEvent( + "behavioral", + BehaviourEventAction.PageView, + commonFields, + commonUserFields, + ) as BehaviourEvent; + event.action = BehaviourEventAction.PageView; + event.screen_name = screen_name; + return event; + } + public navigationEvent( commonFields: CommonFields, commonUserFields: CommonUserFields, diff --git a/web-common/src/metrics/service/BehaviourEventTypes.ts b/web-common/src/metrics/service/BehaviourEventTypes.ts index 62510341cf26..bf0052877c26 100644 --- a/web-common/src/metrics/service/BehaviourEventTypes.ts +++ b/web-common/src/metrics/service/BehaviourEventTypes.ts @@ -4,6 +4,10 @@ import type { MetricsEventScreenName, MetricsEventSpace } from "./MetricsTypes"; import type { SourceEventFields } from "./SourceEventTypes"; export enum BehaviourEventAction { + // PageView fires on every page load and navigation. + // Navigate is narrower: it only fires when the user clicks through to a resource from + // somewhere in the app, and records where they came from. + PageView = "page-view", Navigate = "navigate", DeployIntent = "deploy-intent", diff --git a/web-common/src/metrics/service/MetricsEventFactory.ts b/web-common/src/metrics/service/MetricsEventFactory.ts index 003a37646977..2feab4f4b92c 100644 --- a/web-common/src/metrics/service/MetricsEventFactory.ts +++ b/web-common/src/metrics/service/MetricsEventFactory.ts @@ -19,6 +19,7 @@ export class MetricsEventFactory { event_time: new Date().toISOString(), event_type: eventType, event_name: eventName, + page_url: window.location.href, }; } } diff --git a/web-common/src/metrics/service/MetricsTypes.ts b/web-common/src/metrics/service/MetricsTypes.ts index 0f053d727583..ce600e667436 100644 --- a/web-common/src/metrics/service/MetricsTypes.ts +++ b/web-common/src/metrics/service/MetricsTypes.ts @@ -23,6 +23,10 @@ export interface MetricsEvent extends CommonFields, CommonUserFields { event_time: string; event_type: string; event_name: string; + // The URL the event was fired from. + // This is what lets events be attributed to the dashboard or canvas the user was on, since the + // resource name and the dashboard state (time range, filters, view mode) only exist in the URL. + page_url: string; // Legacy: event_datetime: number; } From 73745740a629624e7451b3e888fdb3d14686ea6f Mon Sep 17 00:00:00 2001 From: Parag Jain Date: Tue, 4 Aug 2026 14:06:44 +0530 Subject: [PATCH 2/3] review comment --- .../src/features/navigation/nav-utils.ts | 26 +++++++++++++++++++ .../[organization]/[project]/+layout.svelte | 24 ++++++++++++----- .../src/metrics/BehaviourEventHandler.ts | 8 +++++- .../metrics/service/BehaviourEventFactory.ts | 6 +++-- .../metrics/service/BehaviourEventTypes.ts | 5 ++++ .../src/metrics/service/MetricsTypes.ts | 9 ++++--- 6 files changed, 65 insertions(+), 13 deletions(-) diff --git a/web-admin/src/features/navigation/nav-utils.ts b/web-admin/src/features/navigation/nav-utils.ts index 9fc0045a8e95..e9fac5dd036e 100644 --- a/web-admin/src/features/navigation/nav-utils.ts +++ b/web-admin/src/features/navigation/nav-utils.ts @@ -178,3 +178,29 @@ export function getScreenNameFromPage(page: Page): MetricsEventScreenName { } return MetricsEventScreenName.Unknown; } + +/** + * Identifies the resource a page is showing, for telemetry. + * Returns empty strings on pages that aren't showing a named resource, in which case consumers fall + * back to parsing the resource out of the page URL. + * + * The type values are also produced by that URL fallback, which lives in the `rill_ui_telemetry_model` + * model of the rill-cloud-metrics project. Keep the two vocabularies in step: they land in the same + * column, so a value only used by one of them reads as two different resource types over time. + */ +export function getResourceFromPage(page: Page): { + type: string; + name: string; +} { + switch (true) { + case isMetricsExplorerPage(page): + return { type: "explore", name: page.params.dashboard ?? "" }; + case isCanvasDashboardPage(page): + return { type: "canvas", name: page.params.dashboard ?? "" }; + case isReportPage(page): + return { type: "report", name: page.params.report ?? "" }; + case isAlertPage(page): + return { type: "alert", name: page.params.alert ?? "" }; + } + return { type: "", name: "" }; +} diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 12fd2f3ad529..a14f23eb9b65 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -29,6 +29,7 @@ getAdminServiceListDeploymentsQueryKey, } from "@rilldata/web-admin/client"; import { + getResourceFromPage, getScreenNameFromPage, isEditPage, isProjectInvitePage, @@ -254,15 +255,24 @@ // Fire a page view for every dashboard, canvas and project page the user opens. // This runs off `page` rather than `afterNavigate` because the first page view has to wait for the // current user query above to resolve, which happens after the initial navigation has completed. - // Only the path is tracked: the query string changes on every filter and time range tweak, which - // would turn page views into an interaction firehose. - let lastTrackedPath: string | undefined; + // + // Views are deduped on the path plus the explore view mode. The view mode is a query param rather + // than a path segment, so keying on the path alone would never record a user switching to pivot or + // time dimension detail. The rest of the query string is deliberately excluded: filters, time range + // and sort change on nearly every interaction, and keying on them would emit an event per click. + let lastTrackedView: string | undefined; $effect(() => { - const path = page.url.pathname; + const trackedView = `${page.url.pathname}?view=${page.url.searchParams.get("view") ?? ""}`; // Events are dropped until loadCloudFields has run, so wait on the same inputs it needs. - if (!project || !$user.data?.user?.id || path === lastTrackedPath) return; - lastTrackedPath = path; - void behaviourEvent?.firePageViewEvent(getScreenNameFromPage(page)); + if (!project || !$user.data?.user?.id || trackedView === lastTrackedView) + return; + lastTrackedView = trackedView; + const resource = getResourceFromPage(page); + void behaviourEvent?.firePageViewEvent( + getScreenNameFromPage(page), + resource.type, + resource.name, + ); }); diff --git a/web-common/src/metrics/BehaviourEventHandler.ts b/web-common/src/metrics/BehaviourEventHandler.ts index 4adc69ad7ccb..f1ef36bf07c4 100644 --- a/web-common/src/metrics/BehaviourEventHandler.ts +++ b/web-common/src/metrics/BehaviourEventHandler.ts @@ -25,10 +25,16 @@ export class BehaviourEventHandler { this.commonUserMetrics = commonUserMetrics; } - public firePageViewEvent(screen_name: MetricsEventScreenName) { + public firePageViewEvent( + screen_name: MetricsEventScreenName, + resource_type: string, + resource_name: string, + ) { return this.metricsService.dispatch("pageViewEvent", [ this.commonUserMetrics, screen_name, + resource_type, + resource_name, ]); } diff --git a/web-common/src/metrics/service/BehaviourEventFactory.ts b/web-common/src/metrics/service/BehaviourEventFactory.ts index 5b5e5207b046..8fbcd139f3e5 100644 --- a/web-common/src/metrics/service/BehaviourEventFactory.ts +++ b/web-common/src/metrics/service/BehaviourEventFactory.ts @@ -15,12 +15,12 @@ import { import type { SourceConnectionType, SourceFileType } from "./SourceEventTypes"; export class BehaviourEventFactory extends MetricsEventFactory { - // The page the user landed on is carried by the base event's `page_url`, so the screen name is - // all this needs on top of it. public pageViewEvent( commonFields: CommonFields, commonUserFields: CommonUserFields, screen_name: MetricsEventScreenName, + resource_type: string, + resource_name: string, ): BehaviourEvent { const event = this.getBaseMetricsEvent( "behavioral", @@ -30,6 +30,8 @@ export class BehaviourEventFactory extends MetricsEventFactory { ) as BehaviourEvent; event.action = BehaviourEventAction.PageView; event.screen_name = screen_name; + event.resource_type = resource_type; + event.resource_name = resource_name; return event; } diff --git a/web-common/src/metrics/service/BehaviourEventTypes.ts b/web-common/src/metrics/service/BehaviourEventTypes.ts index bf0052877c26..f269e17a8e8b 100644 --- a/web-common/src/metrics/service/BehaviourEventTypes.ts +++ b/web-common/src/metrics/service/BehaviourEventTypes.ts @@ -72,6 +72,11 @@ export interface BehaviourEvent screen_name: MetricsEventScreenName; source_screen: MetricsEventScreenName; count: number; + // The resource the page is showing, e.g. "explore" and the name of the explore dashboard. + // Sent unhashed, unlike `entity_id`, because these identify a deployed cloud resource that the + // page URL already names in plain text. + resource_type: string; + resource_name: string; } export interface AddDataBehaviourEventFields { diff --git a/web-common/src/metrics/service/MetricsTypes.ts b/web-common/src/metrics/service/MetricsTypes.ts index ce600e667436..9e4e0fd91ac6 100644 --- a/web-common/src/metrics/service/MetricsTypes.ts +++ b/web-common/src/metrics/service/MetricsTypes.ts @@ -23,9 +23,12 @@ export interface MetricsEvent extends CommonFields, CommonUserFields { event_time: string; event_type: string; event_name: string; - // The URL the event was fired from. - // This is what lets events be attributed to the dashboard or canvas the user was on, since the - // resource name and the dashboard state (time range, filters, view mode) only exist in the URL. + // The URL at the moment the event was fired. + // This is what attributes an event to the dashboard or canvas the user was on, since the resource + // name only exists in the URL. + // Note that the query string is a snapshot, not a history: it reflects the dashboard state at the + // instant of this event, so what it tells you depends on when the event fires. Page views are + // deduped per path and view mode, so their filters and time range are whatever the page loaded with. page_url: string; // Legacy: event_datetime: number; From 8d1b09c4d95dcad76a72936c112ac70b29b5bbae Mon Sep 17 00:00:00 2001 From: Aditya Hegde Date: Wed, 5 Aug 2026 12:52:46 +0530 Subject: [PATCH 3/3] Add throttling of events --- .../[organization]/[project]/+layout.svelte | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index a14f23eb9b65..b719a8a6ef42 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -61,6 +61,9 @@ import type { HTTPError } from "@rilldata/web-common/lib/errors"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts"; import { getRuntimeServiceListResourcesQueryKey } from "@rilldata/web-common/runtime-client"; + import { Throttler } from "@rilldata/web-common/lib/throttler"; + + const PAGE_VIEW_THROTTLE_TIMEOUT = 250; let { children }: { children: Snippet } = $props(); @@ -260,6 +263,14 @@ // than a path segment, so keying on the path alone would never record a user switching to pivot or // time dimension detail. The rest of the query string is deliberately excluded: filters, time range // and sort change on nearly every interaction, and keying on them would emit an event per click. + // + // Events are throttled so that pages the user only passes through, like clicking into a dashboard + // and immediately hitting back, don't each record a view. The throttler keeps the most recent + // callback, so the page the user actually landed on is the one reported. + const pageViewThrottler = new Throttler( + PAGE_VIEW_THROTTLE_TIMEOUT, + PAGE_VIEW_THROTTLE_TIMEOUT, + ); let lastTrackedView: string | undefined; $effect(() => { const trackedView = `${page.url.pathname}?view=${page.url.searchParams.get("view") ?? ""}`; @@ -267,11 +278,15 @@ if (!project || !$user.data?.user?.id || trackedView === lastTrackedView) return; lastTrackedView = trackedView; + // Capture the page's fields now; `page` will have moved on by the time the throttler fires. + const screenName = getScreenNameFromPage(page); const resource = getResourceFromPage(page); - void behaviourEvent?.firePageViewEvent( - getScreenNameFromPage(page), - resource.type, - resource.name, + pageViewThrottler.throttle(() => + behaviourEvent?.firePageViewEvent( + screenName, + resource.type, + resource.name, + ), ); });