From 36afeece02bcbb1da723cee4a5eaeda0f3b33b1a Mon Sep 17 00:00:00 2001 From: Andrei Gaspar Date: Tue, 2 Dec 2025 17:52:15 +0100 Subject: [PATCH] Cross-Domain Tracking and Analytics Refactoring (#2587) * feat: Implement cross-domain tracking (docs -> app) * fix: Eslint config path issue * feat: Implement posthog provider * Revert "fix: Eslint config path issue" This reverts commit 2baf56819a5f9af0b0d18a67b95ae59f857008a0. * feat: Implement wildcard support in analytics cross domain targets --- frontend/app/package.json | 1 + frontend/app/pnpm-lock.yaml | 41 +++++ .../molecules/analytics-provider.tsx | 110 ------------ frontend/app/src/hooks/use-analytics.tsx | 21 +-- frontend/app/src/pages/authenticated.tsx | 6 +- frontend/app/src/providers/posthog.tsx | 167 ++++++++++++++++++ .../components/CrossDomainLinkHandler.tsx | 99 +++++++++++ frontend/docs/mdx-components.js | 17 -- frontend/docs/mdx-components.tsx | 14 ++ frontend/docs/pages/_app.tsx | 11 +- 10 files changed, 341 insertions(+), 146 deletions(-) delete mode 100644 frontend/app/src/components/molecules/analytics-provider.tsx create mode 100644 frontend/app/src/providers/posthog.tsx create mode 100644 frontend/docs/components/CrossDomainLinkHandler.tsx delete mode 100644 frontend/docs/mdx-components.js create mode 100644 frontend/docs/mdx-components.tsx diff --git a/frontend/app/package.json b/frontend/app/package.json index a1ee627d3..93c651a9b 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -81,6 +81,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.446.0", "monaco-themes": "^0.4.4", + "posthog-js": "^1.298.1", "prism-react-renderer": "^2.4.1", "prismjs": "^1.30.0", "qs": "^6.14.0", diff --git a/frontend/app/pnpm-lock.yaml b/frontend/app/pnpm-lock.yaml index 16e69bb27..66aa2fa89 100644 --- a/frontend/app/pnpm-lock.yaml +++ b/frontend/app/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: monaco-themes: specifier: ^0.4.4 version: 0.4.6 + posthog-js: + specifier: ^1.298.1 + version: 1.298.1 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@18.3.1) @@ -751,6 +754,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@posthog/core@1.6.0': + resolution: {integrity: sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2433,6 +2439,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -2916,6 +2925,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3742,6 +3754,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.298.1: + resolution: {integrity: sha512-MynFhC2HO6sg5moUfpkd0s6RzAqcqFX75kjIi4Xrj2Gl0/YQWYvFUgvv8FCpWPKPs2mdvNWYhs+oqJg0BVVHPw==} + + preact@10.28.0: + resolution: {integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4449,6 +4467,9 @@ packages: yaml: optional: true + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4901,6 +4922,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@posthog/core@1.6.0': + dependencies: + cross-spawn: 7.0.6 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.0.0': @@ -6833,6 +6858,8 @@ snapshots: convert-source-map@2.0.0: {} + core-js@3.47.0: {} + cron-parser@4.9.0: dependencies: luxon: 3.7.1 @@ -7426,6 +7453,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -8267,6 +8296,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.298.1: + dependencies: + '@posthog/core': 1.6.0 + core-js: 3.47.0 + fflate: 0.4.8 + preact: 10.28.0 + web-vitals: 4.2.4 + + preact@10.28.0: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -9151,6 +9190,8 @@ snapshots: tsx: 4.20.4 yaml: 2.8.1 + web-vitals@4.2.4: {} + webidl-conversions@3.0.1: {} webpack-sources@3.3.3: {} diff --git a/frontend/app/src/components/molecules/analytics-provider.tsx b/frontend/app/src/components/molecules/analytics-provider.tsx deleted file mode 100644 index 555b4d7f1..000000000 --- a/frontend/app/src/components/molecules/analytics-provider.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { User } from '@/lib/api'; -import { useTenant } from '@/lib/atoms'; -import useApiMeta from '@/pages/auth/hooks/use-api-meta'; -import { - POSTHOG_DISTINCT_ID_LOCAL_STORAGE_KEY, - POSTHOG_SESSION_ID_LOCAL_STORAGE_KEY, - useAnalytics, -} from '@/hooks/use-analytics'; -import React, { PropsWithChildren, useEffect, useMemo } from 'react'; - -interface AnalyticsProviderProps { - user: User; -} - -const AnalyticsProvider: React.FC< - PropsWithChildren & AnalyticsProviderProps -> = ({ user, children }) => { - const meta = useApiMeta(); - - const [loaded, setLoaded] = React.useState(false); - - const { tenant } = useTenant(); - const { identify } = useAnalytics(); - - const config = useMemo(() => { - if (import.meta.env.DEV) { - return { - apiKey: import.meta.env.VITE_PUBLIC_POSTHOG_KEY, - apiHost: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - }; - } - - return meta.data?.posthog; - }, [meta]); - - useEffect(() => { - if (loaded) { - return; - } - - if (tenant && tenant.analyticsOptOut) { - console.log( - 'Skipping Analytics initialization due to opt-out, we respect user privacy.', - ); - return; - } - - if (!config || !tenant) { - return; - } - - let bootstrapConfig = ''; - - if (sessionStorage) { - const distinctId = sessionStorage.getItem( - POSTHOG_DISTINCT_ID_LOCAL_STORAGE_KEY, - ); - const sessionId = sessionStorage.getItem( - POSTHOG_SESSION_ID_LOCAL_STORAGE_KEY, - ); - - if (distinctId && sessionId) { - bootstrapConfig = `bootstrap: ${JSON.stringify({ - sessionID: sessionId, - distinctID: distinctId, - })},`; - } - } - - console.log('Initializing Analytics, opt out in settings.'); - setLoaded(true); - - const posthogScript = ` -!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n { - if (!config) { - return; - } - - setTimeout(() => { - const ref = localStorage.getItem('ref'); - if (ref) { - identify(ref, {}, {}); - } - - if (!user) { - return; - } - - identify(user.metadata.id, { email: user.email, name: user.name }, {}); - }); - }, [user, config, tenant, identify]); - - return children; -}; - -export default AnalyticsProvider; diff --git a/frontend/app/src/hooks/use-analytics.tsx b/frontend/app/src/hooks/use-analytics.tsx index 1e38cab90..0d9af8ae8 100644 --- a/frontend/app/src/hooks/use-analytics.tsx +++ b/frontend/app/src/hooks/use-analytics.tsx @@ -1,8 +1,9 @@ import { useCallback } from 'react'; +import { usePostHog } from 'posthog-js/react'; import { useTenantDetails } from './use-tenant'; interface AnalyticsEvent { - [key: string]: any; + [key: string]: unknown; } interface UseAnalyticsReturn { @@ -23,16 +24,12 @@ export const POSTHOG_SESSION_ID_LOCAL_STORAGE_KEY = 'ph__session_id'; * Provides a clean interface for tracking events and identifying users */ export function useAnalytics(): UseAnalyticsReturn { + const posthog = usePostHog(); const { tenant } = useTenantDetails(); const isAvailable = useCallback(() => { - // Check if PostHog is loaded and user hasn't opted out - return ( - typeof window !== 'undefined' && - (window as any).posthog && - (!tenant || !tenant.analyticsOptOut) - ); - }, [tenant]); + return !!posthog && (!tenant || !tenant.analyticsOptOut); + }, [posthog, tenant]); const capture = useCallback( (eventName: string, properties?: AnalyticsEvent) => { @@ -41,12 +38,12 @@ export function useAnalytics(): UseAnalyticsReturn { } try { - (window as any).posthog.capture(eventName, properties); + posthog.capture(eventName, properties); } catch (error) { console.warn('Analytics capture failed:', error); } }, - [isAvailable], + [posthog, isAvailable], ); const identify = useCallback( @@ -60,12 +57,12 @@ export function useAnalytics(): UseAnalyticsReturn { } try { - (window as any).posthog.identify(userId, properties, setOnceProperties); + posthog.identify(userId, properties, setOnceProperties); } catch (error) { console.warn('Analytics identify failed:', error); } }, - [isAvailable], + [posthog, isAvailable], ); return { diff --git a/frontend/app/src/pages/authenticated.tsx b/frontend/app/src/pages/authenticated.tsx index 296920cb3..446250f71 100644 --- a/frontend/app/src/pages/authenticated.tsx +++ b/frontend/app/src/pages/authenticated.tsx @@ -4,7 +4,7 @@ import api, { queries, TenantVersion, User } from '@/lib/api'; import { Loading } from '@/components/ui/loading.tsx'; import { useMutation, useQuery } from '@tanstack/react-query'; import SupportChat from '@/components/molecules/support-chat'; -import AnalyticsProvider from '@/components/molecules/analytics-provider'; +import { PostHogProvider } from '@/providers/posthog'; import { useState, useEffect } from 'react'; import { useContextFromParent } from '@/lib/outlet'; import { useTenant } from '@/lib/atoms'; @@ -159,7 +159,7 @@ export default function Authenticated() { } return ( - +
-
+ ); } diff --git a/frontend/app/src/providers/posthog.tsx b/frontend/app/src/providers/posthog.tsx new file mode 100644 index 000000000..60e9a1022 --- /dev/null +++ b/frontend/app/src/providers/posthog.tsx @@ -0,0 +1,167 @@ +import { useTenant } from '@/lib/atoms'; +import useApiMeta from '@/pages/auth/hooks/use-api-meta'; +import posthog from 'posthog-js'; +import { PostHogProvider as PhProvider, usePostHog } from 'posthog-js/react'; +import { useEffect, useRef, useMemo, createContext, useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { User } from '@/lib/api'; + +const CROSS_DOMAIN_SESSION_ID_KEY = 'session_id'; +const CROSS_DOMAIN_DISTINCT_ID_KEY = 'distinct_id'; + +interface PostHogContextValue { + isReady: boolean; +} + +const PostHogContext = createContext({ isReady: false }); + +export function usePostHogContext() { + return useContext(PostHogContext); +} + +interface PostHogProviderProps { + children: React.ReactNode; + user: User; +} + +/** + * PostHog Analytics Provider for the Hatchet App + * + * Features: + * - Config from API meta endpoint (or env vars in dev) + * - User identification with email/name + * - Tenant-level analytics opt-out + * - Cross-domain tracking via URL hash bootstrap + * - Session recording with input masking + */ +export function PostHogProvider({ children, user }: PostHogProviderProps) { + const meta = useApiMeta(); + const { tenant } = useTenant(); + const initializedRef = useRef(false); + + const config = useMemo(() => { + if (import.meta.env.DEV) { + return { + apiKey: import.meta.env.VITE_PUBLIC_POSTHOG_KEY, + apiHost: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, + }; + } + return meta.data?.posthog; + }, [meta.data?.posthog]); + + // Check for cross-domain tracking params in URL hash + const bootstrapIds = useMemo(() => { + if (typeof window === 'undefined') { + return null; + } + + const hashParams = new URLSearchParams(window.location.hash.substring(1)); + const sessionId = hashParams.get(CROSS_DOMAIN_SESSION_ID_KEY); + const distinctId = hashParams.get(CROSS_DOMAIN_DISTINCT_ID_KEY); + + if (sessionId && distinctId) { + return { sessionID: sessionId, distinctID: distinctId }; + } + return null; + }, []); + + useEffect(() => { + if (initializedRef.current) { + return; + } + + if (tenant?.analyticsOptOut) { + console.info( + 'Skipping Analytics initialization due to opt-out, we respect user privacy.', + ); + return; + } + + // Need config and tenant to initialize + if (!config?.apiKey || !tenant) { + return; + } + + console.info('Initializing Analytics, opt out in settings.'); + + posthog.init(config.apiKey, { + api_host: config.apiHost || 'https://us.i.posthog.com', + person_profiles: 'identified_only', + capture_pageleave: true, + session_recording: { + maskAllInputs: true, + maskTextSelector: '*', + }, + persistence: 'localStorage+cookie', + bootstrap: bootstrapIds || undefined, + }); + + initializedRef.current = true; + }, [config, tenant, bootstrapIds]); + + // Handle user identification + useEffect(() => { + if (!initializedRef.current || !user) { + return; + } + + const ref = localStorage.getItem('ref'); + if (ref) { + posthog.identify(ref); + } + + posthog.identify(user.metadata.id, { + email: user.email, + name: user.name, + }); + }, [user]); + + // Handle opt-out changes + useEffect(() => { + if (!initializedRef.current) { + return; + } + + if (tenant?.analyticsOptOut) { + posthog.opt_out_capturing(); + posthog.stopSessionRecording?.(); + } else { + posthog.opt_in_capturing(); + } + }, [tenant?.analyticsOptOut]); + + const contextValue: PostHogContextValue = { + isReady: initializedRef.current, + }; + + return ( + + + + {children} + + + ); +} + +function PostHogPageView() { + const location = useLocation(); + const posthogClient = usePostHog(); + + useEffect(() => { + if (!posthogClient) { + return; + } + + let url = window.origin + location.pathname; + if (location.search) { + url = `${url}${location.search}`; + } + + posthogClient.capture('$pageview', { $current_url: url }); + }, [location.pathname, location.search, posthogClient]); + + return null; +} + +export { usePostHog }; diff --git a/frontend/docs/components/CrossDomainLinkHandler.tsx b/frontend/docs/components/CrossDomainLinkHandler.tsx new file mode 100644 index 000000000..3483c0639 --- /dev/null +++ b/frontend/docs/components/CrossDomainLinkHandler.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { usePostHog } from "posthog-js/react"; +import { useEffect } from "react"; + +// Supports exact hostnames or wildcards like "*.onhatchet.run" +// Wildcards match both the base domain and all subdomains +const CROSS_DOMAIN_TARGETS = ["*.onhatchet.run"]; + +function matchesHostnamePattern(hostname: string, pattern: string): boolean { + if (pattern.startsWith("*.")) { + const baseDomain = pattern.slice(2); + return hostname === baseDomain || hostname.endsWith(`.${baseDomain}`); + } + return hostname === pattern; +} + +function shouldHandleLink(href: string): boolean { + try { + const url = new URL(href); + return CROSS_DOMAIN_TARGETS.some((pattern) => + matchesHostnamePattern(url.hostname, pattern), + ); + } catch (error) { + console.error("Invalid URL in link href:", href, error); + return false; + } +} + +function appendTrackingParams( + href: string, + sessionId: string, + distinctId: string, +): string { + const url = new URL(href); + const trackingParams = `session_id=${sessionId}&distinct_id=${distinctId}`; + + if (url.hash && url.hash.length > 1) { + url.hash += `&${trackingParams}`; + } else { + url.hash = trackingParams; + } + + return url.toString(); +} + +/** + * Global click handler that intercepts clicks on links to cross-domain targets + * and appends PostHog session/distinct IDs for cross-domain tracking. + * + * This handles both Markdown-generated links and raw HTML tags in MDX. + */ +export function CrossDomainLinkHandler({ + children, +}: { + children: React.ReactNode; +}) { + const posthog = usePostHog(); + + useEffect(() => { + function handleClick(event: MouseEvent) { + if (!posthog) return; + + const target = event.target as HTMLElement; + const anchor = target.closest("a"); + + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href || !shouldHandleLink(href)) return; + + const sessionId = posthog.get_session_id(); + const distinctId = posthog.get_distinct_id(); + + if (!sessionId || !distinctId) return; + + event.preventDefault(); + + const newHref = appendTrackingParams(href, sessionId, distinctId); + + // Respect the original link's target attribute + const linkTarget = anchor.getAttribute("target"); + + if (linkTarget === "_blank") { + // Preserve rel attribute if present, otherwise use safe defaults + const rel = anchor.getAttribute("rel") || "noopener"; + window.open(newHref, "_blank", rel); + } else { + // Navigate in the same window + window.location.href = newHref; + } + } + + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, [posthog]); + + return <>{children}; +} diff --git a/frontend/docs/mdx-components.js b/frontend/docs/mdx-components.js deleted file mode 100644 index 33e050c60..000000000 --- a/frontend/docs/mdx-components.js +++ /dev/null @@ -1,17 +0,0 @@ -// MDX components for Nextra 4 -import React from 'react'; -import { Callout, Card, Cards, Steps, Tabs, FileTree } from 'nextra/components'; - -export function useMDXComponents(components) { - return { - ...components, - // Adding Nextra components so they can be used in MDX files - Callout, - Card, - Cards, - Steps, - Tabs, - FileTree, - // You can add your custom components here - } -} diff --git a/frontend/docs/mdx-components.tsx b/frontend/docs/mdx-components.tsx new file mode 100644 index 000000000..a1ce29652 --- /dev/null +++ b/frontend/docs/mdx-components.tsx @@ -0,0 +1,14 @@ +// MDX components for Nextra 4 +import { Callout, Cards, Steps, Tabs, FileTree } from "nextra/components"; + +export function useMDXComponents(components: Record) { + return { + ...components, + // Adding Nextra components so they can be used in MDX files + Callout, + Cards, + Steps, + Tabs, + FileTree, + }; +} diff --git a/frontend/docs/pages/_app.tsx b/frontend/docs/pages/_app.tsx index 1051f3292..ea7fa6121 100644 --- a/frontend/docs/pages/_app.tsx +++ b/frontend/docs/pages/_app.tsx @@ -4,16 +4,19 @@ import { LanguageProvider } from "../context/LanguageContext"; import { ConsentProvider } from "../context/ConsentContext"; import CookieConsent from "@/components/ui/cookie-banner"; import { PostHogProvider } from "@/providers/posthog"; +import { CrossDomainLinkHandler } from "@/components/CrossDomainLinkHandler"; function MyApp({ Component, pageProps }: AppProps) { return ( -
- - -
+ +
+ + +
+