From 7226141ea7d256a0bcf796e35d8177c889cdacbe Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Thu, 5 Dec 2024 05:02:21 -0500 Subject: [PATCH] Integrate PostHog session replay (#6176) * Add PostHog API Key to GitHub Action workflows * Add `posthog-js` * Setup PostHog for Rill Developer * Setup PostHog for Rill Cloud * Hand-off sessions from Rill Developer to Rill Cloud * Don't send analytics in `e2e` and `dev` environments * Fix lint * Fix lint * Properly include "Rill version" as a contextual property * Review --- .github/workflows/cli-release.yml | 3 +- .github/workflows/rill-ui.yml | 1 + package-lock.json | 43 ++++++++++++++++++- .../authentication/AvatarButton.svelte | 12 +++++- web-admin/src/routes/+layout.svelte | 3 +- web-admin/src/routes/+layout.ts | 37 ++++++++++++---- web-common/package.json | 1 + .../src/features/project/ProjectDeployer.ts | 19 ++++++-- web-common/src/lib/analytics/posthog.ts | 43 +++++++++++++++++++ web-common/src/vite-env.d.ts | 12 ++++++ web-local/src/routes/+layout.svelte | 23 ++++++++-- 11 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 web-common/src/lib/analytics/posthog.ts create mode 100644 web-common/src/vite-env.d.ts diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index ab82dccdc46..0e2b3c46268 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -52,9 +52,10 @@ jobs: - name: Build and embed static UI run: make cli.prepare env: - RILL_UI_PUBLIC_PYLON_APP_ID: "26a0fdd2-3bd3-41e2-82bc-1b35a444729f" RILL_UI_PUBLIC_INTAKE_USER: "data-modeler" RILL_UI_PUBLIC_INTAKE_PASSWORD: ${{ secrets.RILL_INTAKE_PASSWORD }} + RILL_UI_PUBLIC_POSTHOG_API_KEY: "phc_4qnfUotXUuevk2zJN8ei8HgKXMynddEMI0wPI9XwzlS" + RILL_UI_PUBLIC_PYLON_APP_ID: "26a0fdd2-3bd3-41e2-82bc-1b35a444729f" - name: Build Rill using Goreleaser run: |- diff --git a/.github/workflows/rill-ui.yml b/.github/workflows/rill-ui.yml index 86939a555c0..38d23672156 100644 --- a/.github/workflows/rill-ui.yml +++ b/.github/workflows/rill-ui.yml @@ -72,6 +72,7 @@ jobs: npm run build -w web-admin env: RILL_UI_PUBLIC_RILL_ADMIN_URL: https://admin.${{ env.DOMAIN }} + RILL_UI_PUBLIC_POSTHOG_API_KEY: "phc_4qnfUotXUuevk2zJN8ei8HgKXMynddEMI0wPI9XwzlS" RILL_UI_PUBLIC_PYLON_APP_ID: "26a0fdd2-3bd3-41e2-82bc-1b35a444729f" RILL_UI_PUBLIC_VERSION: ${{ env.RILL_VERSION }} diff --git a/package-lock.json b/package-lock.json index 90d950660eb..1c47d842fa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12644,7 +12644,9 @@ } }, "node_modules/core-js": { - "version": "3.36.0", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", + "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -16200,6 +16202,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -25633,6 +25642,30 @@ "postcss": "^8.2.15" } }, + "node_modules/posthog-js": { + "version": "1.188.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.188.0.tgz", + "integrity": "sha512-FdNCZcgM5sjADxES7VWbRntD39V2fvHunZry6Rrsp8VDG20TcAWc+koAuCMfEoU5jKxm/Ua37QnI9Xqfwg2fow==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-js": "^3.38.1", + "fflate": "^0.4.8", + "preact": "^10.19.3", + "web-vitals": "^4.2.0" + } + }, + "node_modules/preact": { + "version": "10.25.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.0.tgz", + "integrity": "sha512-6bYnzlLxXV3OSpUxLdaxBmE7PMOu0aR3pG6lryK/0jmvcDFPlcXGQAt5DpK3RITWiDrfYZRI0druyaK/S9kYLg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -33610,6 +33643,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/web-worker": { "version": "1.3.0", "dev": true, @@ -35252,6 +35292,7 @@ "match-sorter": "^6.3.1", "nearley": "^2.20.1", "orval": "6.12.0", + "posthog-js": "^1.188.0", "regular-table": "^0.5.9", "storybook": "^7.0.18", "svelte": "^4.2.19", diff --git a/web-admin/src/features/authentication/AvatarButton.svelte b/web-admin/src/features/authentication/AvatarButton.svelte index 4f5a71368c7..957ab40d60b 100644 --- a/web-admin/src/features/authentication/AvatarButton.svelte +++ b/web-admin/src/features/authentication/AvatarButton.svelte @@ -2,11 +2,12 @@ import { page } from "$app/stores"; import { redirectToLogout } from "@rilldata/web-admin/client/redirect-utils"; import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu"; - import { createAdminServiceGetCurrentUser } from "../../client"; import { initPylonChat, type UserLike, } from "@rilldata/web-common/features/help/initPylonChat"; + import { posthogIdentify } from "@rilldata/web-common/lib/analytics/posthog"; + import { createAdminServiceGetCurrentUser } from "../../client"; import ProjectAccessControls from "../projects/ProjectAccessControls.svelte"; import ViewAsUserPopover from "../view-as-user/ViewAsUserPopover.svelte"; @@ -14,7 +15,14 @@ let subMenuOpen = false; - $: if ($user.data?.user) initPylonChat($user.data.user as UserLike); + $: if ($user.data?.user) { + // Actions to take when the user is known + posthogIdentify($user.data.user.id, { + email: $user.data.user.email, + }); + initPylonChat($user.data.user as UserLike); + } + $: ({ params } = $page); function handlePylon() { diff --git a/web-admin/src/routes/+layout.svelte b/web-admin/src/routes/+layout.svelte index 8a7162fb127..c80bbbb8946 100644 --- a/web-admin/src/routes/+layout.svelte +++ b/web-admin/src/routes/+layout.svelte @@ -13,6 +13,7 @@ import BannerCenter from "@rilldata/web-common/components/banner/BannerCenter.svelte"; import NotificationCenter from "@rilldata/web-common/components/notifications/NotificationCenter.svelte"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; + import { initPylonWidget } from "@rilldata/web-common/features/help/initPylonWidget"; import RillTheme from "@rilldata/web-common/layout/RillTheme.svelte"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; import { errorEventHandler } from "@rilldata/web-common/metrics/initMetrics"; @@ -20,7 +21,6 @@ import { onMount } from "svelte"; import ErrorBoundary from "../features/errors/ErrorBoundary.svelte"; import { createGlobalErrorCallback } from "../features/errors/error-utils"; - import { initPylonWidget } from "@rilldata/web-common/features/help/initPylonWidget"; import TopNavigationBar from "../features/navigation/TopNavigationBar.svelte"; export let data; @@ -39,6 +39,7 @@ featureFlags.set(true, "adminServer", "readOnly"); let removeJavascriptListeners: () => void; + initCloudMetrics() .then(() => { removeJavascriptListeners = diff --git a/web-admin/src/routes/+layout.ts b/web-admin/src/routes/+layout.ts index a63239e10d0..6362a8a5427 100644 --- a/web-admin/src/routes/+layout.ts +++ b/web-admin/src/routes/+layout.ts @@ -5,6 +5,7 @@ ensure the same single-page app behavior in development. */ export const ssr = false; +import { dev } from "$app/environment"; import { adminServiceGetProject, getAdminServiceGetProjectQueryKey, @@ -13,8 +14,9 @@ import { } from "@rilldata/web-admin/client"; import { redirectToLoginOrRequestAccess } from "@rilldata/web-admin/features/authentication/checkUserAccess"; import { fetchOrganizationPermissions } from "@rilldata/web-admin/features/organizations/selectors"; +import { initPosthog } from "@rilldata/web-common/lib/analytics/posthog"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.js"; -import { error, type Page } from "@sveltejs/kit"; +import { error, redirect, type Page } from "@sveltejs/kit"; import type { QueryFunction, QueryKey } from "@tanstack/svelte-query"; import { adminServiceGetProjectWithBearerToken, @@ -22,6 +24,7 @@ import { } from "../features/public-urls/get-project-with-bearer-token.js"; export const load = async ({ params, url, route }) => { + // Route params const { organization, project, token: routeToken } = params; const pageState = { url, @@ -35,6 +38,30 @@ export const load = async ({ params, url, route }) => { } const token = searchParamToken ?? routeToken; + // Initialize analytics + const shouldSendAnalytics = !import.meta.env.VITE_PLAYWRIGHT_TEST && !dev; + if (shouldSendAnalytics) { + const rillVersion = import.meta.env.RILL_UI_VERSION; + const posthogSessionId = url.searchParams.get("ph_session_id") as + | string + | null; + initPosthog(rillVersion, posthogSessionId); + if (posthogSessionId) { + // Remove the PostHog sessionID from the url + url.searchParams.delete("ph_session_id"); + throw redirect(307, url.toString()); + } + } + + // If no organization or project, return empty permissions + if (!organization || !project) { + return { + organizationPermissions: {}, + projectPermissions: {}, + }; + } + + // Get organization permissions let organizationPermissions: V1OrganizationPermissions = {}; if (organization && !token) { try { @@ -47,13 +74,7 @@ export const load = async ({ params, url, route }) => { } } - if (!organization || !project) { - return { - organizationPermissions, - projectPermissions: {}, - }; - } - + // Get project permissions let queryKey: QueryKey; let queryFn: QueryFunction< Awaited> diff --git a/web-common/package.json b/web-common/package.json index f6af206c258..226629d4b46 100644 --- a/web-common/package.json +++ b/web-common/package.json @@ -73,6 +73,7 @@ "match-sorter": "^6.3.1", "nearley": "^2.20.1", "orval": "6.12.0", + "posthog-js": "^1.188.0", "regular-table": "^0.5.9", "storybook": "^7.0.18", "svelte": "^4.2.19", diff --git a/web-common/src/features/project/ProjectDeployer.ts b/web-common/src/features/project/ProjectDeployer.ts index 3f847fc6ad3..a6e06085823 100644 --- a/web-common/src/features/project/ProjectDeployer.ts +++ b/web-common/src/features/project/ProjectDeployer.ts @@ -26,6 +26,7 @@ import { localServiceGetCurrentUser, } from "@rilldata/web-common/runtime-client/local-service"; import { derived, get, writable } from "svelte/store"; +import { addPosthogSessionIdToUrl } from "../../lib/analytics/posthog"; export class ProjectDeployer { public readonly metadata = createLocalServiceGetMetadata(); @@ -132,6 +133,8 @@ export class ProjectDeployer { await waitUntil(() => !get(this.project).isLoading); const projectResp = get(this.project).data as GetCurrentProjectResponse; + + // Project already exists if (projectResp.project) { if (projectResp.project.githubUrl) { // we do not support pushing to a project already connected to github @@ -142,10 +145,14 @@ export class ProjectDeployer { projectId: projectResp.project.id, reupload: true, }); - window.open(resp.frontendUrl, "_self"); + const projectUrl = resp.frontendUrl; // https://ui.rilldata.com// + const projectUrlWithSessionId = addPosthogSessionIdToUrl(projectUrl); + window.open(projectUrlWithSessionId, "_self"); return; } + // Project does not yet exist + if (!org && this.useOrg) { org = this.useOrg; } @@ -160,12 +167,18 @@ export class ProjectDeployer { checkNextOrg = inferredCheckNextOrg; } - const frontendUrl = await this.tryDeployWithOrg( + const projectUrl = await this.tryDeployWithOrg( org, projectResp.localProjectName, checkNextOrg, ); - if (frontendUrl) window.open(frontendUrl + "/-/invite", "_self"); + if (projectUrl) { + // projectUrl: https://ui.rilldata.com// + const projectInviteUrl = projectUrl + "/-/invite"; + const projectInviteUrlWithSessionId = + addPosthogSessionIdToUrl(projectInviteUrl); + window.open(projectInviteUrlWithSessionId, "_self"); + } } private async inferOrg(rillUserOrgs: string[]) { diff --git a/web-common/src/lib/analytics/posthog.ts b/web-common/src/lib/analytics/posthog.ts new file mode 100644 index 00000000000..70e47256622 --- /dev/null +++ b/web-common/src/lib/analytics/posthog.ts @@ -0,0 +1,43 @@ +import posthog, { type Properties } from "posthog-js"; + +const POSTHOG_API_KEY = import.meta.env.RILL_UI_PUBLIC_POSTHOG_API_KEY; + +export function initPosthog(rillVersion: string, sessionId?: string | null) { + // No need to proceed if PostHog is already initialized + if (posthog.__loaded) return; + + if (!POSTHOG_API_KEY) { + console.warn("PostHog API Key not found"); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + posthog.init(POSTHOG_API_KEY, { + api_host: "https://us.i.posthog.com", // TODO: use a reverse proxy https://posthog.com/docs/advanced/proxy + session_recording: { + maskAllInputs: true, + maskTextSelector: "*", + recordHeaders: true, + recordBody: false, + }, + autocapture: true, + enable_heatmaps: true, + bootstrap: { + sessionID: sessionId ?? undefined, + }, + loaded: (posthog) => { + posthog.register_for_session({ + "Rill version": rillVersion, + }); + }, + }); +} + +export function posthogIdentify(userID: string, userProperties?: Properties) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + posthog.identify(userID, userProperties); +} + +export function addPosthogSessionIdToUrl(url: string) { + return url + "?ph_session_id=" + posthog.get_session_id(); +} diff --git a/web-common/src/vite-env.d.ts b/web-common/src/vite-env.d.ts new file mode 100644 index 00000000000..6224928d932 --- /dev/null +++ b/web-common/src/vite-env.d.ts @@ -0,0 +1,12 @@ +// This file lets Typescript know about our custom environment variables +// See: https://vite.dev/guide/env-and-mode#intellisense-for-typescript + +/// + +interface ImportMetaEnv { + readonly RILL_UI_PUBLIC_POSTHOG_API_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web-local/src/routes/+layout.svelte b/web-local/src/routes/+layout.svelte index b0b49b6243d..f4c12882b3a 100644 --- a/web-local/src/routes/+layout.svelte +++ b/web-local/src/routes/+layout.svelte @@ -1,4 +1,6 @@