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 6166d3af3296..a14f23eb9b65 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -29,6 +29,8 @@ getAdminServiceListDeploymentsQueryKey, } from "@rilldata/web-admin/client"; import { + getResourceFromPage, + getScreenNameFromPage, isEditPage, isProjectInvitePage, isProjectPage, @@ -51,7 +53,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 +251,29 @@ }); } }); + + // 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. + // + // 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 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 || trackedView === lastTrackedView) + return; + lastTrackedView = trackedView; + const resource = getResourceFromPage(page); + void behaviourEvent?.firePageViewEvent( + getScreenNameFromPage(page), + resource.type, + resource.name, + ); + }); {#if error} diff --git a/web-common/src/metrics/BehaviourEventHandler.ts b/web-common/src/metrics/BehaviourEventHandler.ts index 923c2dee7f6b..f1ef36bf07c4 100644 --- a/web-common/src/metrics/BehaviourEventHandler.ts +++ b/web-common/src/metrics/BehaviourEventHandler.ts @@ -25,6 +25,19 @@ export class BehaviourEventHandler { this.commonUserMetrics = commonUserMetrics; } + public firePageViewEvent( + screen_name: MetricsEventScreenName, + resource_type: string, + resource_name: string, + ) { + return this.metricsService.dispatch("pageViewEvent", [ + this.commonUserMetrics, + screen_name, + resource_type, + resource_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..8fbcd139f3e5 100644 --- a/web-common/src/metrics/service/BehaviourEventFactory.ts +++ b/web-common/src/metrics/service/BehaviourEventFactory.ts @@ -15,6 +15,26 @@ import { import type { SourceConnectionType, SourceFileType } from "./SourceEventTypes"; export class BehaviourEventFactory extends MetricsEventFactory { + public pageViewEvent( + commonFields: CommonFields, + commonUserFields: CommonUserFields, + screen_name: MetricsEventScreenName, + resource_type: string, + resource_name: string, + ): BehaviourEvent { + const event = this.getBaseMetricsEvent( + "behavioral", + BehaviourEventAction.PageView, + commonFields, + commonUserFields, + ) as BehaviourEvent; + event.action = BehaviourEventAction.PageView; + event.screen_name = screen_name; + event.resource_type = resource_type; + event.resource_name = resource_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..f269e17a8e8b 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", @@ -68,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/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..9e4e0fd91ac6 100644 --- a/web-common/src/metrics/service/MetricsTypes.ts +++ b/web-common/src/metrics/service/MetricsTypes.ts @@ -23,6 +23,13 @@ export interface MetricsEvent extends CommonFields, CommonUserFields { event_time: string; event_type: string; event_name: string; + // 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; }