mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-02-17 21:59:05 -06:00
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:
@@ -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",
|
||||
|
||||
41
frontend/app/pnpm-lock.yaml
generated
41
frontend/app/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
167
frontend/app/src/providers/posthog.tsx
Normal file
167
frontend/app/src/providers/posthog.tsx
Normal 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 };
|
||||
99
frontend/docs/components/CrossDomainLinkHandler.tsx
Normal file
99
frontend/docs/components/CrossDomainLinkHandler.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
14
frontend/docs/mdx-components.tsx
Normal file
14
frontend/docs/mdx-components.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user