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 2baf56819a.

* feat: Implement wildcard support in analytics cross domain targets
This commit is contained in:
Andrei Gaspar
2025-12-02 17:52:15 +01:00
committed by GitHub
parent 3f5c243325
commit 36afeece02
10 changed files with 341 additions and 146 deletions

View File

@@ -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",

View File

@@ -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: {}

View File

@@ -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<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${config.apiKey}',{
api_host:'${config.apiHost}',
${bootstrapConfig}
session_recording: {
maskAllInputs: true,
maskTextSelector: "*"
}
})
`;
document.head.appendChild(document.createElement('script')).innerHTML =
posthogScript;
}, [config, loaded, tenant]);
useEffect(() => {
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;

View File

@@ -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 {

View File

@@ -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 (
<AnalyticsProvider user={userQuery.data}>
<PostHogProvider user={userQuery.data}>
<SupportChat user={userQuery.data}>
<div className="flex flex-row flex-1 w-full h-full">
<MainNav
@@ -174,6 +174,6 @@ export default function Authenticated() {
</div>
</div>
</SupportChat>
</AnalyticsProvider>
</PostHogProvider>
);
}

View File

@@ -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<PostHogContextValue>({ 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 (
<PostHogContext.Provider value={contextValue}>
<PhProvider client={posthog}>
<PostHogPageView />
{children}
</PhProvider>
</PostHogContext.Provider>
);
}
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 };

View File

@@ -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 <a> 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}</>;
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,14 @@
// MDX components for Nextra 4
import { Callout, Cards, Steps, Tabs, FileTree } from "nextra/components";
export function useMDXComponents(components: Record<string, unknown>) {
return {
...components,
// Adding Nextra components so they can be used in MDX files
Callout,
Cards,
Steps,
Tabs,
FileTree,
};
}

View File

@@ -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 (
<LanguageProvider>
<ConsentProvider>
<PostHogProvider>
<main>
<CookieConsent />
<Component {...pageProps} />
</main>
<CrossDomainLinkHandler>
<main>
<CookieConsent />
<Component {...pageProps} />
</main>
</CrossDomainLinkHandler>
</PostHogProvider>
</ConsentProvider>
</LanguageProvider>