diff --git a/.env.example b/.env.example index fa329d7d..ed295fca 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ INDEXSUPPLY_API_KEY= SLACK_FEEDBACK_WEBHOOK= # e.g. https://hooks.slack.com/services/... VITE_BASE_URL= # e.g. https://docs.tempo.xyz +VITE_ANALYTICS_RELEASE= # e.g. docs_growth_v1 VITE_GA_MEASUREMENT_ID= VITE_POSTHOG_HOST= VITE_POSTHOG_KEY= diff --git a/env.d.ts b/env.d.ts index 5a86c157..4c86845a 100644 --- a/env.d.ts +++ b/env.d.ts @@ -2,6 +2,7 @@ interface EnvironmentVariables { readonly NODE_ENV: 'development' | 'production' | 'test' readonly VITE_BASE_URL: string + readonly VITE_ANALYTICS_RELEASE: string readonly VITE_POSTHOG_KEY: string readonly VITE_POSTHOG_HOST: string readonly VITE_TEMPO_ENV: 'localnet' | 'devnet' | 'moderato' diff --git a/src/components/DocsHeader.tsx b/src/components/DocsHeader.tsx index 23bcb3b0..edc005df 100644 --- a/src/components/DocsHeader.tsx +++ b/src/components/DocsHeader.tsx @@ -4,6 +4,7 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 're import { useConfig } from 'vocs' import { useRouter } from 'waku' import { DOCS_SEARCH_PARAM } from '../lib/docs-search' +import { trackDocsSearchOpened } from '../lib/posthog' import { AmpLogo, ClaudeLogo, CodexLogo } from './AgentLogos' type MegaLink = { @@ -1098,6 +1099,7 @@ export default function DocsHeader() { cancelClose() setActiveMenu(null) close() + trackDocsSearchOpened('header') openDocsSearch() } @@ -1105,6 +1107,18 @@ export default function DocsHeader() { warmMarketingApp() }, []) + useEffect(() => { + const trackKeyboardSearch = (event: KeyboardEvent) => { + if (!event.isTrusted) return + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + trackDocsSearchOpened('keyboard') + } + } + + document.addEventListener('keydown', trackKeyboardSearch) + return () => document.removeEventListener('keydown', trackKeyboardSearch) + }, []) + // Open the search dialog when arriving from the marketing site's search CTA // (which navigates to `/docs?search=1`). Strip the param so refresh/back // doesn't reopen it. @@ -1119,6 +1133,7 @@ export default function DocsHeader() { '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`, ) + trackDocsSearchOpened('marketing') openDocsSearchWhenReady() }, []) diff --git a/src/components/PostHogSetup.tsx b/src/components/PostHogSetup.tsx index cc9032f9..0c523b44 100644 --- a/src/components/PostHogSetup.tsx +++ b/src/components/PostHogSetup.tsx @@ -1,50 +1,255 @@ 'use client' import { useEffect } from 'react' +import { + markPostHogReady, + trackDocsCopyCode, + trackDocsCtaClick, + trackDocsSearchResultClick, +} from '../lib/posthog' +import { + classifyStrategicCta, + DEFAULT_ANALYTICS_RELEASE, + enrichPostHogCapture, + getAnalyticsRuntimeProperties, + hasUnsafeAnalyticsQuery, + isProductionAnalyticsHost, + sanitizeAnalyticsUrl, + sanitizePostHogCapture, +} from '../lib/posthog-analytics' + +const POSTHOG_UI_HOST = 'https://us.posthog.com' + +function eventTargetElement(event: Event) { + return event.target instanceof Element ? event.target : null +} + +function ctaLocation(anchor: HTMLAnchorElement) { + if (anchor.closest('header, nav')) return 'nav' + if (anchor.closest('footer')) return 'footer' + if (anchor.closest('aside')) return 'sidebar' + return 'content' +} + +function codeLanguage(button: HTMLButtonElement) { + const container = button.closest('pre') ?? button.parentElement + const code = container?.querySelector('code') + const explicit = code?.getAttribute('data-language') ?? container?.getAttribute('data-language') + if (explicit) return explicit.toLowerCase().slice(0, 32) + + const languageClass = [...(code?.classList ?? [])].find((name) => name.startsWith('language-')) + return languageClass?.slice('language-'.length, 32).toLowerCase() +} + +function copyMetadata(button: HTMLButtonElement) { + const label = button.getAttribute('aria-label')?.toLowerCase() ?? '' + const isAgentInstall = label.startsWith('copy tempo') + const isShell = button.hasAttribute('data-v-shell-copy') || label.includes('command') + const isOpenApiCopy = + button.hasAttribute('data-v-openapi-action') && + (label.includes('copy') || button.textContent?.trim().toLowerCase() === 'copy') + const isCodeCopy = label === 'copy code' + + if (!isAgentInstall && !isShell && !isOpenApiCopy && !isCodeCopy) return null + + return { + copyType: isAgentInstall || isShell ? ('command' as const) : ('code' as const), + copySource: isAgentInstall + ? 'agent_install' + : isShell + ? 'shell' + : isOpenApiCopy + ? 'openapi' + : 'code_block', + language: codeLanguage(button), + } +} + +function findVocsSearchInput() { + return document.querySelector( + '[role="combobox"][aria-controls="search-results"]', + ) +} + +function vocsSearchResultMetadata(anchor: HTMLAnchorElement) { + const results = anchor.closest('#search-results') + if (!results) return null + + const options = [...results.querySelectorAll('[role="option"]')] + const option = anchor.closest('[role="option"]') + const rank = option ? options.indexOf(option) + 1 : 0 + const url = new URL(anchor.href, window.location.origin) + return { + queryLength: findVocsSearchInput()?.value.trim().length ?? 0, + resultPath: url.pathname, + resultRank: Math.max(rank, 0), + resultType: url.hash ? 'section' : 'page', + } +} function PostHogInitializer({ site }: { site: string }) { useEffect(() => { const posthogKey = import.meta.env.VITE_POSTHOG_KEY - const posthogHost = import.meta.env.VITE_POSTHOG_HOST + if (!posthogKey) return + if (!isProductionAnalyticsHost(window.location.hostname)) return - if (!posthogKey || !posthogHost) return + const analyticsRelease = + import.meta.env.VITE_ANALYTICS_RELEASE?.trim() || DEFAULT_ANALYTICS_RELEASE - const init = async () => { + let cancelled = false + const initializePostHog = async () => { const { default: posthog } = await import('posthog-js') + if (cancelled) return + + const registerBrowserContext = () => { + posthog.register(getAnalyticsRuntimeProperties(site, analyticsRelease)) + markPostHogReady(posthog) + } + + if (posthog.__loaded) { + registerBrowserContext() + return + } posthog.init(posthogKey, { api_host: '/ingest', - ui_host: posthogHost, - defaults: '2025-11-30', - capture_exceptions: true, - debug: import.meta.env.MODE === 'development', + ui_host: POSTHOG_UI_HOST, + defaults: '2026-05-30', + capture_pageview: 'history_change', + capture_pageleave: 'if_capture_pageview', + enable_heatmaps: true, + capture_dead_clicks: true, + cross_subdomain_cookie: true, + secure_cookie: true, + person_profiles: 'identified_only', + autocapture: { + dom_event_allowlist: ['click', 'submit'], + element_allowlist: ['a', 'button', 'form'], + css_selector_ignorelist: [ + '.ph-no-capture', + '[data-ph-no-autocapture]', + '[data-ph-sensitive]', + 'input', + 'textarea', + 'select', + '[contenteditable="true"]', + ], + element_attribute_ignorelist: [ + 'value', + 'data-value', + 'data-secret', + 'data-private', + 'aria-valuetext', + ], + capture_copied_text: false, + }, + mask_all_text: true, + mask_all_element_attributes: true, session_recording: { - maskAllInputs: false, - maskInputOptions: { - password: true, + maskAllInputs: true, + maskTextSelector: + 'input, textarea, select, [contenteditable="true"], [data-ph-sensitive]', + blockSelector: '[data-ph-sensitive]', + recordHeaders: false, + recordBody: false, + captureCanvas: { recordCanvas: false }, + collectFonts: false, + maskCapturedNetworkRequestFn: (request) => { + const name = sanitizeAnalyticsUrl(request.name) + if (!name) return undefined + return { + ...request, + name, + requestHeaders: undefined, + requestBody: undefined, + responseHeaders: undefined, + responseBody: undefined, + } }, }, + disable_session_recording: hasUnsafeAnalyticsQuery(window.location.href), + enable_recording_console_log: false, + before_send: (capture) => + sanitizePostHogCapture( + enrichPostHogCapture(capture, window.location.pathname, document.title), + ), + capture_exceptions: true, + debug: import.meta.env.MODE === 'development', + loaded: registerBrowserContext, }) - posthog.register({ site }) } + void initializePostHog() - if ('requestIdleCallback' in window) { - const idleId = window.requestIdleCallback(init, { timeout: 2_000 }) - return () => window.cancelIdleCallback(idleId) + let lastSearchSelection = '' + let lastSearchSelectionAt = 0 + + const captureSearchResult = (anchor: HTMLAnchorElement) => { + const metadata = vocsSearchResultMetadata(anchor) + if (!metadata) return false + + const selectionKey = `${metadata.resultPath}:${metadata.resultRank}` + const now = Date.now() + if (selectionKey === lastSearchSelection && now - lastSearchSelectionAt < 500) return true + + lastSearchSelection = selectionKey + lastSearchSelectionAt = now + trackDocsSearchResultClick(metadata) + return true } - const timeoutId = globalThis.setTimeout(init, 1) - return () => globalThis.clearTimeout(timeoutId) + const handleClick = (event: MouseEvent) => { + const target = eventTargetElement(event) + if (!target) return + + const anchor = target.closest('a[href]') + if (anchor) { + if (captureSearchResult(anchor)) return + + const cta = classifyStrategicCta(anchor.href, window.location.pathname) + if (cta) + trackDocsCtaClick({ + ...cta, + ctaText: anchor.textContent ?? undefined, + location: ctaLocation(anchor), + }) + } + + const button = target.closest('button') + if (!button) return + const metadata = copyMetadata(button) + if (metadata) trackDocsCopyCode(metadata) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter') return + const target = eventTargetElement(event) + if (!(target instanceof HTMLInputElement)) return + if (target.getAttribute('aria-controls') !== 'search-results') return + + const results = document.getElementById('search-results') + const option = results?.querySelector( + '[role="option"][aria-selected="true"], [role="option"][data-selected="true"], [role="option"]', + ) + const anchor = + option instanceof HTMLAnchorElement + ? option + : option?.querySelector('a[href]') + if (anchor) captureSearchResult(anchor) + } + + window.addEventListener('click', handleClick, true) + window.addEventListener('keydown', handleKeyDown, true) + return () => { + cancelled = true + window.removeEventListener('click', handleClick, true) + window.removeEventListener('keydown', handleKeyDown, true) + } }, [site]) return null } export default function PostHogSetup({ site = 'docs' }: { site?: string }) { - const posthogKey = import.meta.env.VITE_POSTHOG_KEY - const posthogHost = import.meta.env.VITE_POSTHOG_HOST - - if (!posthogKey || !posthogHost) return null - + if (!import.meta.env.VITE_POSTHOG_KEY) return null return } diff --git a/src/lib/posthog-analytics.test.ts b/src/lib/posthog-analytics.test.ts new file mode 100644 index 00000000..bafa45ba --- /dev/null +++ b/src/lib/posthog-analytics.test.ts @@ -0,0 +1,301 @@ +import type { CaptureResult } from 'posthog-js' +import { describe, expect, it } from 'vitest' +import { + classifyAnalyticsPage, + classifyStrategicCta, + DEFAULT_ANALYTICS_RELEASE, + enrichPostHogCapture, + getAnalyticsPageSection, + getAnalyticsRuntimeProperties, + hasUnsafeAnalyticsQuery, + isProductionAnalyticsHost, + sanitizeAnalyticsPath, + sanitizeAnalyticsUrl, + sanitizePostHogCapture, +} from './posthog-analytics' + +describe('sanitizeAnalyticsUrl', () => { + it('keeps campaign attribution and removes arbitrary query values and fragments', () => { + expect( + sanitizeAnalyticsUrl( + '/developers/docs/api?utm_source=chatgpt&gclid=abc123&email=juan%40example.com#token', + ), + ).toBe('/developers/docs/api?utm_source=chatgpt&gclid=present') + }) + + it('removes URL credentials and redacts blockchain identifiers', () => { + expect( + sanitizeAnalyticsUrl( + 'https://user:secret@explorer.tempo.xyz/tx/0x1234567890abcdef1234567890abcdef?tab=logs', + ), + ).toBe('https://explorer.tempo.xyz/tx/:identifier') + }) + + it('leaves non-URL labels unchanged', () => { + expect(sanitizeAnalyticsUrl('accept payments')).toBe('accept payments') + }) + + it('fails closed for malformed or unsupported URL values', () => { + expect(sanitizeAnalyticsUrl('https://%zz')).toBe('') + expect(sanitizeAnalyticsUrl('javascript:alert(1)')).toBe('') + }) + + it('removes contact destinations and unsafe campaign values', () => { + expect(sanitizeAnalyticsUrl('mailto:person@example.com')).toBe('mailto:') + expect(sanitizeAnalyticsUrl('tel:+15555555555')).toBe('tel:') + expect( + sanitizeAnalyticsUrl( + '/docs?utm_source=person%40example.com&utm_campaign=enterprise&query=private', + ), + ).toBe('/docs?utm_campaign=enterprise') + expect(sanitizeAnalyticsUrl('/docs?gclid=secret-click-id')).toBe('/docs?gclid=present') + }) + + it('detects URLs that must not be included in session replay', () => { + expect(hasUnsafeAnalyticsQuery('/docs?utm_source=chatgpt')).toBe(false) + expect(hasUnsafeAnalyticsQuery('/docs?utm_source=person%40example.com')).toBe(true) + expect(hasUnsafeAnalyticsQuery('/docs?utm_email=juan')).toBe(true) + expect(hasUnsafeAnalyticsQuery('/docs?gclid=raw-click-id')).toBe(true) + expect(hasUnsafeAnalyticsQuery('/docs?query=customer-secret')).toBe(true) + }) +}) + +describe('sanitizePostHogCapture', () => { + it('removes sensitive custom properties and sanitizes nested URLs', () => { + const capture = { + event: 'docs_copy_code', + properties: { + command_text: 'pnpm secret-command', + search_query: 'customer api key', + $current_url: 'https://tempo.xyz/docs?utm_source=perplexity&token=secret#section', + $session_entry_utm_source: 'person@example.com', + $gclid: 'raw-click-id', + $elements: [ + { + attr__href: '/docs/api?utm_campaign=launch&api_key=secret#authentication', + }, + ], + }, + } as unknown as CaptureResult + + const sanitized = sanitizePostHogCapture(capture) + expect(sanitized?.properties).toEqual({ + $current_url: 'https://tempo.xyz/docs?utm_source=perplexity', + $gclid: 'present', + $elements: [{ attr__href: '/docs/api?utm_campaign=launch' }], + }) + }) + + it('redacts native paths and URL keys inside heatmap payloads', () => { + const walletPath = '/developers/docs/address/0x1234567890abcdef1234567890abcdef' + const heatmapUrl = `https://tempo.xyz${walletPath}?email=person%40example.com&utm_source=docs` + const capture = { + event: '$$heatmap', + properties: { + $pathname: walletPath, + $prev_pageview_pathname: '/developers/docs/session/123e4567-e89b-42d3-a456-426614174000', + page_path: walletPath, + result_path: walletPath, + $heatmap_data: { + [heatmapUrl]: [{ type: 'click', x: 10, y: 20 }], + }, + }, + } as unknown as CaptureResult + + const sanitized = sanitizePostHogCapture(capture) + expect(sanitized?.properties).toEqual({ + $pathname: '/developers/docs/address/:identifier', + $prev_pageview_pathname: '/developers/docs/session/:identifier', + page_path: '/developers/docs/address/:identifier', + result_path: '/developers/docs/address/:identifier', + $heatmap_data: { + 'https://tempo.xyz/developers/docs/address/:identifier?utm_source=docs': [ + { type: 'click', x: 10, y: 20 }, + ], + }, + }) + expect(JSON.stringify(sanitized)).not.toContain('person%40example.com') + expect(JSON.stringify(sanitized)).not.toContain('1234567890abcdef') + }) + + it('removes blockchain identifiers from event and person properties', () => { + const capture = { + event: 'docs_demo_step_complete', + properties: { + wallet_address: '0x1234567890abcdef1234567890abcdef', + sender_address: '0xsender', + recipient_address: '0xrecipient', + tx_hash: '0xtransaction', + transaction_hash: '0xtransaction', + memo: 'customer payroll', + nested: { + $wallet_address: '0xnested', + safe_label: 'step_complete', + }, + }, + $set: { + wallet_address: '0xperson', + role: 'developer', + }, + $set_once: { + memo: 'private memo', + first_surface: 'docs', + }, + } as unknown as CaptureResult + + expect(sanitizePostHogCapture(capture)).toEqual({ + event: 'docs_demo_step_complete', + properties: { + nested: { safe_label: 'step_complete' }, + }, + $set: { role: 'developer' }, + $set_once: { first_surface: 'docs' }, + }) + }) + + it('adds normalized page context before applying path privacy', () => { + const capture = { + event: '$pageview', + properties: { + $pathname: '/developers/docs/api/authentication', + }, + } as unknown as CaptureResult + + const sanitized = sanitizePostHogCapture( + enrichPostHogCapture( + capture, + '/developers/docs/api/authentication', + 'Authentication | Tempo Docs', + ), + ) + expect(sanitized?.properties).toMatchObject({ + $pathname: '/developers/docs/api/authentication', + page_path: '/docs/api/authentication', + page_type: 'docs', + page_section: 'api', + page_title: 'Authentication | Tempo Docs', + }) + }) +}) + +describe('analytics runtime context', () => { + it.each([ + 'tempo.xyz', + 'www.tempo.xyz', + 'docs.tempo.xyz', + 'developers.tempo.xyz', + 'mainnet.docs.tempo.xyz', + ])('allows the production host %s', (hostname) => { + expect(isProductionAnalyticsHost(hostname)).toBe(true) + }) + + it.each([ + 'localhost', + 'tempo-docs-preview.vercel.app', + 'tempo.xyz.example.com', + ])('rejects non-production host %s', (hostname) => { + expect(isProductionAnalyticsHost(hostname)).toBe(false) + }) + + it('registers factual browser and release metadata', () => { + expect(getAnalyticsRuntimeProperties('docs', 'release_2026_07_11')).toEqual({ + site: 'docs', + surface: 'docs', + tempo_app_id: 'docs', + client_type: 'browser', + release: 'release_2026_07_11', + }) + expect(getAnalyticsRuntimeProperties('docs', 'not a safe release')).toMatchObject({ + release: DEFAULT_ANALYTICS_RELEASE, + }) + }) +}) + +describe('analytics page classification', () => { + it.each([ + ['/developers', 'developer_home', 'home'], + ['/developers/build/tip20-tokens', 'developer_feature', 'build'], + ['/developers/blog/fees', 'developer_blog', 'blog'], + ['/developers/performance', 'developer_performance', 'performance'], + ['/docs/api/authentication', 'docs', 'api'], + ] as const)('classifies %s', (path, pageType, section) => { + expect(classifyAnalyticsPage(path)).toBe(pageType) + expect(getAnalyticsPageSection(path)).toBe(section) + }) + + it('redacts wallet-like segments from analytics paths', () => { + expect( + sanitizeAnalyticsPath( + '/developers/docs/address/0x1234567890abcdef1234567890abcdef?token=secret', + ), + ).toBe('/developers/docs/address/:identifier') + }) +}) + +describe('strategic CTA classification', () => { + it('classifies contact intent from the current page', () => { + expect( + classifyStrategicCta('https://tempo.xyz/contact', '/developers/docs/api/authentication'), + ).toEqual({ + ctaId: 'contact_api_access', + ctaType: 'contact_api_access', + destination: 'https://tempo.xyz/contact', + destinationCategory: 'contact', + destinationHost: 'tempo.xyz', + destinationKind: 'internal', + destinationPath: '/contact', + conversionIntent: 'contact', + conversionDetail: 'api_access', + }) + }) + + it('classifies high-value developer destinations', () => { + expect( + classifyStrategicCta('/developers/docs/quickstart/integrate-tempo', '/developers/build'), + ).toEqual({ + ctaId: 'integrate_tempo', + ctaType: 'integrate_tempo', + destination: 'https://tempo.xyz/developers/docs/quickstart/integrate-tempo', + destinationCategory: 'get_started', + destinationHost: 'tempo.xyz', + destinationKind: 'internal', + destinationPath: '/docs/quickstart/integrate-tempo', + conversionIntent: 'developer', + conversionDetail: 'integrate_tempo', + }) + }) + + it.each([ + 'docs.tempo.xyz', + 'developers.tempo.xyz', + ])('classifies the first-payment guide on %s without calling it activation', (hostname) => { + expect( + classifyStrategicCta( + `https://${hostname}/developers/docs/guide/payments/send-a-payment`, + '/developers/docs/quickstart/faucet', + ), + ).toMatchObject({ + ctaId: 'send_first_payment_guide', + ctaType: 'send_first_payment_guide', + destinationCategory: 'guide', + destinationHost: hostname, + destinationKind: 'internal', + destinationPath: '/docs/guide/payments/send-a-payment', + conversionIntent: 'developer', + conversionDetail: 'send_first_payment_guide', + }) + }) + + it('does not classify ordinary documentation navigation as a CTA', () => { + expect(classifyStrategicCta('/docs/protocol', '/docs')).toBeNull() + }) + + it('does not classify a lookalike host as a strategic docs CTA', () => { + expect( + classifyStrategicCta( + 'https://tempo.xyz.example.com/docs/guide/payments/send-a-payment', + '/docs', + ), + ).toBeNull() + }) +}) diff --git a/src/lib/posthog-analytics.ts b/src/lib/posthog-analytics.ts new file mode 100644 index 00000000..05abd3b1 --- /dev/null +++ b/src/lib/posthog-analytics.ts @@ -0,0 +1,498 @@ +import type { CaptureResult } from 'posthog-js' + +const campaignQueryParameters = new Set([ + 'dclid', + 'fbclid', + 'gbraid', + 'gclid', + 'li_fat_id', + 'mc_cid', + 'mc_eid', + 'msclkid', + 'sccid', + 'ttclid', + 'twclid', + 'wbraid', +]) + +const utmQueryParameters = new Set([ + 'utm_campaign', + 'utm_content', + 'utm_creative_format', + 'utm_id', + 'utm_marketing_tactic', + 'utm_medium', + 'utm_source', + 'utm_source_platform', + 'utm_term', +]) + +const safeCampaignValuePattern = /^[a-z\d][a-z\d._~+-]{0,127}$/i + +const blockedPropertyNames = new Set([ + 'api_key', + 'authorization', + 'code_snippet', + 'command_text', + 'email', + 'feedback_message', + 'memo', + 'password', + 'phone', + 'private_key', + 'recipient_address', + 'search_query', + 'sender_address', + 'transaction_hash', + 'tx_hash', + 'wallet_address', +]) + +const maxCampaignValueLength = 256 +const sensitivePathSegmentPatterns = [ + /^0x[a-f\d]{16,}$/i, + /^[a-f\d]{64}$/i, + /^[a-f\d]{8}-[a-f\d]{4}-[1-5][a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i, +] + +const productionAnalyticsHosts = new Set([ + 'developers.tempo.xyz', + 'docs.tempo.xyz', + 'mainnet.docs.tempo.xyz', + 'tempo.xyz', + 'www.tempo.xyz', +]) + +export const DEFAULT_ANALYTICS_RELEASE = 'docs_growth_v1' + +export type AnalyticsPageType = + | 'developer_blog' + | 'developer_feature' + | 'developer_home' + | 'developer_performance' + | 'docs' + | 'other' + +export type AnalyticsConversionIntent = 'contact' | 'developer' | 'none' +export type AnalyticsDestinationKind = 'external' | 'internal' + +export type StrategicCta = { + ctaId: string + ctaType: string + destination: string + destinationCategory: string + destinationHost: string + destinationKind: AnalyticsDestinationKind + destinationPath: string + conversionIntent: AnalyticsConversionIntent + conversionDetail?: string | undefined +} + +export function isProductionAnalyticsHost(hostname: string) { + return productionAnalyticsHosts.has(hostname.trim().toLowerCase().replace(/\.$/, '')) +} + +export function getAnalyticsRuntimeProperties(site: string, release = DEFAULT_ANALYTICS_RELEASE) { + const safeRelease = /^[a-z\d][a-z\d._-]{0,63}$/i.test(release) + ? release + : DEFAULT_ANALYTICS_RELEASE + + return { + site, + surface: site, + tempo_app_id: site, + client_type: 'browser', + release: safeRelease, + } +} + +function shouldKeepQueryParameter(name: string) { + const normalized = name.toLowerCase() + return utmQueryParameters.has(normalized) || campaignQueryParameters.has(normalized) +} + +function campaignPropertyKind(name: string) { + const normalized = name.toLowerCase().replace(/^\$+/, '') + if ( + /(?:^|_)utm_(?:source|medium|campaign|term|content|id|source_platform|creative_format|marketing_tactic)$/.test( + normalized, + ) + ) + return 'campaign' + if ( + [...campaignQueryParameters].some( + (parameter) => normalized === parameter || normalized.endsWith(`_${parameter}`), + ) + ) + return 'click_id' + return null +} + +function sanitizeCampaignValue(kind: 'campaign' | 'click_id', value: unknown) { + if (typeof value !== 'string' || !value) return undefined + if (kind === 'click_id') return 'present' + return safeCampaignValuePattern.test(value) ? value : undefined +} + +export function hasUnsafeAnalyticsQuery(value: string) { + try { + const url = new URL(value, 'https://tempo.xyz') + for (const [name, campaignValue] of url.searchParams.entries()) { + const normalized = name.toLowerCase() + if (campaignQueryParameters.has(normalized)) return true + if (!utmQueryParameters.has(normalized)) return true + if (!sanitizeCampaignValue('campaign', campaignValue)) return true + } + return false + } catch { + return true + } +} + +function redactSensitivePathSegments(pathname: string) { + return pathname + .split('/') + .map((segment) => { + let decoded = segment + try { + decoded = decodeURIComponent(segment) + } catch { + // Keep malformed segments as-is. + } + return sensitivePathSegmentPatterns.some((pattern) => pattern.test(decoded)) + ? ':identifier' + : segment + }) + .join('/') +} + +/** + * Removes arbitrary query parameters, URL credentials, and hashes while + * preserving standard campaign attribution parameters. + */ +export function sanitizeAnalyticsUrl(value: string) { + const input = value.trim() + if (!input) return input + + if (/^(?:mailto|tel):/i.test(input)) return input.replace(/:.*/, ':') + + const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(input) + const isAbsolute = /^https?:\/\//i.test(input) + const isProtocolRelative = input.startsWith('//') + const isRelativeUrl = input.startsWith('/') || input.startsWith('?') || input.startsWith('#') + if (hasScheme && !isAbsolute) return '' + if (!isAbsolute && !isProtocolRelative && !isRelativeUrl) return value + + try { + const url = new URL(input, 'https://tempo.xyz') + if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' + + url.username = '' + url.password = '' + url.hash = '' + url.pathname = redactSensitivePathSegments(url.pathname) + + for (const name of [...new Set(url.searchParams.keys())]) { + if (!shouldKeepQueryParameter(name)) { + url.searchParams.delete(name) + continue + } + + const kind = utmQueryParameters.has(name.toLowerCase()) ? 'campaign' : 'click_id' + const values = url.searchParams.getAll(name) + url.searchParams.delete(name) + for (const item of values) { + const sanitized = sanitizeCampaignValue(kind, item.slice(0, maxCampaignValueLength)) + if (sanitized) url.searchParams.append(name, sanitized) + } + } + + if (isAbsolute || isProtocolRelative) return url.toString() + return `${url.pathname}${url.search}` + } catch { + return '' + } +} + +export function sanitizeAnalyticsPath(value: string) { + const input = value.trim() + if (!input) return input + + try { + const url = new URL(input, 'https://tempo.xyz') + if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' + return redactSensitivePathSegments(url.pathname) + } catch { + return '' + } +} + +function isUrlProperty(name: string) { + const normalized = name.toLowerCase() + return ( + normalized.includes('current_url') || + normalized.includes('page_url') || + normalized.includes('referrer') || + normalized.includes('result_url') || + normalized.endsWith('href') || + normalized.endsWith('_url') + ) +} + +function isPathProperty(name: string) { + const normalized = name.toLowerCase() + return normalized === 'path' || normalized.endsWith('_path') || normalized.endsWith('pathname') +} + +function sanitizeHeatmapData(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + + const sanitized: Record = {} + for (const [rawUrl, points] of Object.entries(value as Record)) { + const safeUrl = sanitizeAnalyticsUrl(rawUrl) + if (!safeUrl || !/^https?:\/\//i.test(safeUrl)) continue + + const safePoints = sanitizePropertyValue('$heatmap_points', points) + const existing = sanitized[safeUrl] + sanitized[safeUrl] = + Array.isArray(existing) && Array.isArray(safePoints) + ? [...existing, ...safePoints] + : safePoints + } + + return sanitized +} + +function sanitizePropertyValue(name: string, value: unknown): unknown { + const normalizedName = name.toLowerCase().replace(/^\$+/, '') + if (blockedPropertyNames.has(normalizedName)) return undefined + if (name === '$heatmap_data') return sanitizeHeatmapData(value) + const campaignKind = campaignPropertyKind(name) + if (campaignKind) return sanitizeCampaignValue(campaignKind, value) + if (typeof value === 'string' && isPathProperty(name)) return sanitizeAnalyticsPath(value) + if (typeof value === 'string' && isUrlProperty(name)) return sanitizeAnalyticsUrl(value) + if (Array.isArray(value)) + return value + .map((item) => sanitizePropertyValue(name, item)) + .filter((item) => item !== undefined) + if (!value || typeof value !== 'object' || value instanceof Date) return value + return sanitizePropertyRecord(value as Record) +} + +function sanitizePropertyRecord(properties: Record) { + const sanitized: Record = {} + + for (const [name, value] of Object.entries(properties)) { + const nextValue = sanitizePropertyValue(name, value) + if (nextValue !== undefined) sanitized[name] = nextValue + } + + return sanitized +} + +/** Final guardrail applied to every browser event before PostHog sends it. */ +export function sanitizePostHogCapture(capture: CaptureResult | null): CaptureResult | null { + if (!capture) return null + + return { + ...capture, + properties: sanitizePropertyRecord((capture.properties ?? {}) as Record), + $set: capture.$set + ? sanitizePropertyRecord(capture.$set as Record) + : undefined, + $set_once: capture.$set_once + ? sanitizePropertyRecord(capture.$set_once as Record) + : undefined, + } as CaptureResult +} + +export function normalizeAnalyticsPath(pathname: string) { + const withoutDevelopersPrefix = pathname.replace(/^\/developers(?=\/|$)/, '') || '/' + return withoutDevelopersPrefix.replace(/\/+$/, '') || '/' +} + +export function classifyAnalyticsPage(pathname: string): AnalyticsPageType { + const path = normalizeAnalyticsPath(pathname) + if (path === '/' || path === '/build') return 'developer_home' + if (path === '/docs' || path.startsWith('/docs/')) return 'docs' + if (path === '/blog' || path.startsWith('/blog/')) return 'developer_blog' + if (path === '/performance') return 'developer_performance' + if (path.startsWith('/build/')) return 'developer_feature' + return 'other' +} + +export function getAnalyticsPageSection(pathname: string) { + const path = normalizeAnalyticsPath(pathname) + if (path === '/docs') return 'overview' + if (path.startsWith('/docs/')) return path.split('/')[2] || 'overview' + if (path === '/') return 'home' + return path.split('/')[1] || 'home' +} + +export function getAnalyticsPageContext(pathname: string, title = '') { + const pagePath = sanitizeAnalyticsPath(normalizeAnalyticsPath(pathname)) || '/' + return { + page_path: pagePath, + page_type: classifyAnalyticsPage(pagePath), + page_section: getAnalyticsPageSection(pagePath), + page_title: title.trim().slice(0, 240), + } +} + +export function enrichPostHogCapture( + capture: CaptureResult | null, + pathname: string, + title = '', +): CaptureResult | null { + if (!capture) return null + return { + ...capture, + properties: { + ...getAnalyticsPageContext(pathname, title), + ...(capture.properties ?? {}), + }, + } as CaptureResult +} + +function contactConversionDetail(pathname: string) { + const path = normalizeAnalyticsPath(pathname) + if (path === '/docs/api' || path.startsWith('/docs/api/')) return 'api_access' + if (path.includes('/zones')) return 'zones_design_partner' + if (path.startsWith('/docs/guide/node/validator')) return 'validator_interest' + if (path === '/docs/partners' || path.startsWith('/docs/ecosystem')) return 'partner_interest' + return 'general_contact' +} + +function normalizeAnalyticsHostname(hostname: string) { + return hostname.toLowerCase().replace(/^www\./, '') +} + +function buildStrategicCta( + url: URL, + properties: { + ctaId: string + destinationCategory: string + conversionIntent: AnalyticsConversionIntent + conversionDetail?: string | undefined + }, +): StrategicCta { + const destinationHost = normalizeAnalyticsHostname(url.hostname) + return { + ...properties, + ctaType: properties.ctaId, + destination: sanitizeAnalyticsUrl(url.toString()), + destinationHost, + destinationKind: isProductionAnalyticsHost(url.hostname) ? 'internal' : 'external', + destinationPath: sanitizeAnalyticsPath(normalizeAnalyticsPath(url.pathname)), + } +} + +/** Returns stable metadata only for destinations worth treating as funnel CTAs. */ +export function classifyStrategicCta(href: string, currentPathname: string): StrategicCta | null { + try { + const url = new URL(href, 'https://tempo.xyz') + const hostname = normalizeAnalyticsHostname(url.hostname) + const path = normalizeAnalyticsPath(url.pathname) + const isDocsSurfaceHost = isProductionAnalyticsHost(url.hostname) + + if (hostname === 'tempo.xyz' && /^\/contact\/?$/.test(url.pathname)) { + const conversionDetail = contactConversionDetail(currentPathname) + return buildStrategicCta(url, { + ctaId: `contact_${conversionDetail}`, + destinationCategory: 'contact', + conversionIntent: 'contact', + conversionDetail, + }) + } + + if (hostname === 'wallet.tempo.xyz') + return buildStrategicCta(url, { + ctaId: 'tempo_wallet', + destinationCategory: 'wallet', + conversionIntent: 'developer', + conversionDetail: 'wallet', + }) + if ( + hostname === 'faucet.tempo.xyz' || + (isDocsSurfaceHost && path === '/docs/quickstart/faucet') + ) + return buildStrategicCta(url, { + ctaId: 'tempo_faucet', + destinationCategory: 'faucet', + conversionIntent: 'developer', + conversionDetail: 'faucet', + }) + if (hostname === 'explorer.tempo.xyz') + return buildStrategicCta(url, { + ctaId: 'tempo_explorer', + destinationCategory: 'explorer', + conversionIntent: 'developer', + conversionDetail: 'explorer', + }) + if (hostname === 'github.com' && url.pathname.toLowerCase().startsWith('/tempoxyz')) + return buildStrategicCta(url, { + ctaId: 'tempo_github', + destinationCategory: 'source', + conversionIntent: 'developer', + conversionDetail: 'source', + }) + if (hostname === 'mpp.dev') + return buildStrategicCta(url, { + ctaId: 'mpp_docs', + destinationCategory: 'mpp', + conversionIntent: 'developer', + conversionDetail: 'mpp', + }) + + const strategicDocsPaths: Record< + string, + Pick + > = { + '/docs/guide/machine-payments': { + ctaId: 'machine_payments_guide', + destinationCategory: 'guide', + conversionIntent: 'developer', + conversionDetail: 'machine_payments_guide', + }, + '/docs/guide/payments/accept-a-payment': { + ctaId: 'accept_payments_guide', + destinationCategory: 'guide', + conversionIntent: 'developer', + conversionDetail: 'accept_payments_guide', + }, + '/docs/guide/payments/send-a-payment': { + ctaId: 'send_first_payment_guide', + destinationCategory: 'guide', + conversionIntent: 'developer', + conversionDetail: 'send_first_payment_guide', + }, + '/docs/guide/using-tempo-with-ai': { + ctaId: 'tempo_ai_guide', + destinationCategory: 'agent_setup', + conversionIntent: 'developer', + conversionDetail: 'tempo_ai_guide', + }, + '/docs/quickstart/integrate-tempo': { + ctaId: 'integrate_tempo', + destinationCategory: 'get_started', + conversionIntent: 'developer', + conversionDetail: 'integrate_tempo', + }, + } + const strategicDocsCta = strategicDocsPaths[path] + if (isDocsSurfaceHost && strategicDocsCta) return buildStrategicCta(url, strategicDocsCta) + + if (hostname === 'tempo.xyz' && path === '/') { + return buildStrategicCta(url, { + ctaId: 'tempo_marketing_site', + destinationCategory: 'marketing', + conversionIntent: 'none', + conversionDetail: 'marketing_site', + }) + } + + return null + } catch { + return null + } +} diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 61d01c1b..7cb07b40 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -1,174 +1,266 @@ 'use client' -import posthog from 'posthog-js' +import { + classifyStrategicCta, + getAnalyticsPageContext, + isProductionAnalyticsHost, + normalizeAnalyticsPath, + type StrategicCta, + sanitizeAnalyticsPath, + sanitizeAnalyticsUrl, +} from './posthog-analytics' -/** - * PostHog event names - * Following naming convention: UPPERCASE_WITH_UNDERSCORE - */ export const POSTHOG_EVENTS = { - // Page views PAGE_VIEW: 'docs_page_view', - - // Link clicks INTERNAL_LINK_CLICK: 'docs_internal_link_click', EXTERNAL_LINK_CLICK: 'docs_external_link_click', NAVIGATION_LINK_CLICK: 'docs_navigation_link_click', - - // Button clicks BUTTON_CLICK: 'docs_button_click', CTA_CLICK: 'docs_cta_click', - - // Copy actions COPY_CODE: 'docs_copy_code', - COPY_COMMAND: 'docs_copy_command', - - // Demo interactions DEMO_START: 'docs_demo_start', DEMO_STEP_COMPLETE: 'docs_demo_step_complete', DEMO_SOURCE_CLICK: 'docs_demo_source_click', - - // Search - SEARCH_QUERY: 'docs_search_query', + SEARCH_OPENED: 'docs_search_opened', SEARCH_RESULT_CLICK: 'docs_search_result_click', - - // Code interactions CODE_EXAMPLE_VIEW: 'docs_code_example_view', CODE_EXAMPLE_COPY: 'docs_code_example_copy', - - // Feedback FEEDBACK_SUBMITTED: 'docs_feedback_submitted', FEEDBACK_HELPFUL: 'docs_feedback_helpful', FEEDBACK_NOT_HELPFUL: 'docs_feedback_not_helpful', } as const -/** - * PostHog event property names - * Following naming convention: UPPERCASE_WITH_UNDERSCORE - */ export const POSTHOG_PROPERTIES = { - // Site identification SITE: 'site', - - // Common properties PAGE_PATH: 'page_path', + PAGE_TYPE: 'page_type', + PAGE_SECTION: 'page_section', PAGE_TITLE: 'page_title', LINK_URL: 'link_url', LINK_TEXT: 'link_text', BUTTON_TEXT: 'button_text', BUTTON_VARIANT: 'button_variant', + CTA_ID: 'cta_id', + CTA_TYPE: 'cta_type', + DESTINATION: 'destination', + DESTINATION_CATEGORY: 'destination_category', + DESTINATION_HOST: 'destination_host', + DESTINATION_KIND: 'destination_kind', + DESTINATION_PATH: 'destination_path', + CONVERSION_DETAIL: 'conversion_detail', + CONVERSION_INTENT: 'conversion_intent', EXTERNAL_DOMAIN: 'external_domain', - - // Code-related properties + EVENT_FAMILY: 'event_family', + LOCATION: 'location', CODE_LANGUAGE: 'code_language', - CODE_SNIPPET: 'code_snippet', - COMMAND_TEXT: 'command_text', - - // Demo properties + COPY_TYPE: 'copy_type', + COPY_SOURCE: 'copy_source', DEMO_NAME: 'demo_name', DEMO_STEP: 'demo_step', DEMO_STEP_NAME: 'demo_step_name', - - // Search properties - SEARCH_QUERY: 'search_query', - SEARCH_RESULT_TITLE: 'search_result_title', - SEARCH_RESULT_URL: 'search_result_url', - - // Feedback properties + QUERY_LENGTH: 'query_length', + RESULT_PATH: 'result_path', + RESULT_RANK: 'result_rank', + RESULT_TYPE: 'result_type', + SEARCH_SOURCE: 'search_source', FEEDBACK_HELPFUL: 'feedback_helpful', FEEDBACK_CATEGORY: 'feedback_category', - FEEDBACK_MESSAGE: 'feedback_message', FEEDBACK_PAGE_URL: 'feedback_page_url', } as const -/** - * Hook to access PostHog instance - */ +type EventProperties = Record +type PendingEvent = { event: string; properties: EventProperties } +type PostHogClient = typeof import('posthog-js')['default'] + +const pendingEvents: PendingEvent[] = [] +const maxPendingEvents = 50 +let posthogReady = false +let posthogClient: PostHogClient | null = null + +function currentPageContext(): EventProperties { + if (typeof window === 'undefined') return {} + return getAnalyticsPageContext( + window.location.pathname, + typeof document === 'undefined' ? '' : document.title, + ) +} + +export function captureDocsEvent(event: string, properties: EventProperties = {}) { + if (typeof window === 'undefined') return + + const payload = { ...currentPageContext(), ...properties } + if (posthogReady && posthogClient?.__loaded) { + posthogClient.capture(event, payload) + return + } + + if (pendingEvents.length >= maxPendingEvents) pendingEvents.shift() + pendingEvents.push({ event, properties: payload }) +} + +/** Called after PostHog has registered the browser surface context. */ +export function markPostHogReady(client: PostHogClient) { + posthogClient = client + posthogReady = true + for (const pending of pendingEvents.splice(0)) { + posthogClient.capture(pending.event, pending.properties) + } +} + +export function trackDocsSearchOpened(source: 'header' | 'keyboard' | 'marketing') { + captureDocsEvent(POSTHOG_EVENTS.SEARCH_OPENED, { + [POSTHOG_PROPERTIES.SEARCH_SOURCE]: source, + }) +} + +export function trackDocsSearchResultClick(properties: { + queryLength: number + resultPath: string + resultRank: number + resultType: string +}) { + captureDocsEvent(POSTHOG_EVENTS.SEARCH_RESULT_CLICK, { + [POSTHOG_PROPERTIES.QUERY_LENGTH]: Math.max(0, properties.queryLength), + [POSTHOG_PROPERTIES.RESULT_PATH]: normalizeAnalyticsPath(properties.resultPath), + [POSTHOG_PROPERTIES.RESULT_RANK]: properties.resultRank, + [POSTHOG_PROPERTIES.RESULT_TYPE]: properties.resultType, + }) +} + +export function trackDocsCopyCode(properties: { + copyType: 'code' | 'command' + copySource: string + language?: string | undefined +}) { + captureDocsEvent(POSTHOG_EVENTS.COPY_CODE, { + [POSTHOG_PROPERTIES.COPY_TYPE]: properties.copyType, + [POSTHOG_PROPERTIES.COPY_SOURCE]: properties.copySource, + [POSTHOG_PROPERTIES.CODE_LANGUAGE]: properties.language, + }) +} + +export function trackDocsCtaClick( + properties: StrategicCta & { + ctaText?: string | undefined + location: string + }, +) { + captureDocsEvent(POSTHOG_EVENTS.CTA_CLICK, { + [POSTHOG_PROPERTIES.EVENT_FAMILY]: 'cta_click', + [POSTHOG_PROPERTIES.CTA_ID]: properties.ctaId, + [POSTHOG_PROPERTIES.CTA_TYPE]: properties.ctaType, + [POSTHOG_PROPERTIES.BUTTON_TEXT]: safeLinkLabel(properties.ctaText), + [POSTHOG_PROPERTIES.DESTINATION]: properties.destination, + [POSTHOG_PROPERTIES.LOCATION]: properties.location, + [POSTHOG_PROPERTIES.DESTINATION_PATH]: properties.destinationPath, + [POSTHOG_PROPERTIES.DESTINATION_HOST]: properties.destinationHost, + [POSTHOG_PROPERTIES.DESTINATION_KIND]: properties.destinationKind, + [POSTHOG_PROPERTIES.DESTINATION_CATEGORY]: properties.destinationCategory, + [POSTHOG_PROPERTIES.CONVERSION_INTENT]: properties.conversionIntent, + [POSTHOG_PROPERTIES.CONVERSION_DETAIL]: properties.conversionDetail, + }) +} + +function safeLinkLabel(value?: string) { + return value?.trim().slice(0, 80) || undefined +} + export function usePostHogTracking() { return { - posthog, - /** - * Track a page view - */ + posthog: posthogClient, trackPageView: (path: string, title?: string) => { - posthog?.capture(POSTHOG_EVENTS.PAGE_VIEW, { - [POSTHOG_PROPERTIES.PAGE_PATH]: path, + captureDocsEvent(POSTHOG_EVENTS.PAGE_VIEW, { + [POSTHOG_PROPERTIES.PAGE_PATH]: normalizeAnalyticsPath(path), [POSTHOG_PROPERTIES.PAGE_TITLE]: title || document.title, }) }, - /** - * Track an internal link click - */ trackInternalLinkClick: (url: string, linkText?: string) => { - posthog?.capture(POSTHOG_EVENTS.INTERNAL_LINK_CLICK, { - [POSTHOG_PROPERTIES.LINK_URL]: url, - [POSTHOG_PROPERTIES.LINK_TEXT]: linkText, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + captureDocsEvent(POSTHOG_EVENTS.INTERNAL_LINK_CLICK, { + [POSTHOG_PROPERTIES.LINK_URL]: sanitizeAnalyticsUrl(url), + [POSTHOG_PROPERTIES.LINK_TEXT]: safeLinkLabel(linkText), }) }, - /** - * Track an external link click - */ trackExternalLinkClick: (url: string, linkText?: string) => { try { - const domain = new URL(url).hostname - posthog?.capture(POSTHOG_EVENTS.EXTERNAL_LINK_CLICK, { - [POSTHOG_PROPERTIES.LINK_URL]: url, - [POSTHOG_PROPERTIES.LINK_TEXT]: linkText, - [POSTHOG_PROPERTIES.EXTERNAL_DOMAIN]: domain, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + const parsedUrl = new URL(url) + captureDocsEvent(POSTHOG_EVENTS.EXTERNAL_LINK_CLICK, { + [POSTHOG_PROPERTIES.LINK_URL]: sanitizeAnalyticsUrl(url), + [POSTHOG_PROPERTIES.LINK_TEXT]: safeLinkLabel(linkText), + [POSTHOG_PROPERTIES.EXTERNAL_DOMAIN]: parsedUrl.hostname, }) } catch { - // Invalid URL, skip tracking + // Invalid URL, skip tracking. } }, - /** - * Track a button click - */ trackButtonClick: ( buttonText: string, variant?: string, additionalProps?: Record, ) => { - posthog?.capture(POSTHOG_EVENTS.BUTTON_CLICK, { - [POSTHOG_PROPERTIES.BUTTON_TEXT]: buttonText, + captureDocsEvent(POSTHOG_EVENTS.BUTTON_CLICK, { + [POSTHOG_PROPERTIES.BUTTON_TEXT]: safeLinkLabel(buttonText), [POSTHOG_PROPERTIES.BUTTON_VARIANT]: variant, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, ...additionalProps, }) }, - /** - * Track a CTA click - */ trackCTAClick: (ctaText: string, destination?: string) => { - posthog?.capture(POSTHOG_EVENTS.CTA_CLICK, { - [POSTHOG_PROPERTIES.BUTTON_TEXT]: ctaText, - [POSTHOG_PROPERTIES.LINK_URL]: destination, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + const classified = destination + ? classifyStrategicCta(destination, window.location.pathname) + : null + if (classified) { + trackDocsCtaClick({ + ...classified, + ctaText, + location: 'interactive_demo', + }) + return + } + + let destinationPath: string | undefined + let destinationHost: string | undefined + let destinationKind: 'external' | 'internal' | undefined + if (destination) { + try { + const url = new URL(destination, window.location.origin) + destinationPath = sanitizeAnalyticsPath(normalizeAnalyticsPath(url.pathname)) + destinationHost = url.hostname.toLowerCase().replace(/^www\./, '') + destinationKind = isProductionAnalyticsHost(url.hostname) ? 'internal' : 'external' + } catch { + // Keep the generic CTA event without destination metadata. + } + } + + captureDocsEvent(POSTHOG_EVENTS.CTA_CLICK, { + [POSTHOG_PROPERTIES.EVENT_FAMILY]: 'cta_click', + [POSTHOG_PROPERTIES.CTA_ID]: 'interactive_demo_cta', + [POSTHOG_PROPERTIES.CTA_TYPE]: 'interactive_demo', + [POSTHOG_PROPERTIES.BUTTON_TEXT]: safeLinkLabel(ctaText), + [POSTHOG_PROPERTIES.DESTINATION]: destination + ? sanitizeAnalyticsUrl(destination) + : undefined, + [POSTHOG_PROPERTIES.LOCATION]: 'interactive_demo', + [POSTHOG_PROPERTIES.DESTINATION_PATH]: destinationPath, + [POSTHOG_PROPERTIES.DESTINATION_HOST]: destinationHost, + [POSTHOG_PROPERTIES.DESTINATION_KIND]: destinationKind, + [POSTHOG_PROPERTIES.DESTINATION_CATEGORY]: 'other', + [POSTHOG_PROPERTIES.CONVERSION_INTENT]: 'none', }) }, - /** - * Track a copy action - */ - trackCopy: (type: 'code' | 'command', content: string, language?: string) => { - const eventName = type === 'code' ? POSTHOG_EVENTS.COPY_CODE : POSTHOG_EVENTS.COPY_COMMAND - - posthog?.capture(eventName, { - [POSTHOG_PROPERTIES.CODE_LANGUAGE]: language, - [POSTHOG_PROPERTIES.COMMAND_TEXT]: type === 'command' ? content : undefined, - [POSTHOG_PROPERTIES.CODE_SNIPPET]: type === 'code' ? content.substring(0, 100) : undefined, // Limit length - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + // The copied value is intentionally ignored. Code, commands, wallet + // addresses, and transaction hashes must never be sent to PostHog. + trackCopy: (type: 'code' | 'command', _content: string, language?: string) => { + trackDocsCopyCode({ + copyType: type, + copySource: 'interactive_demo', + language, }) }, - /** - * Track a demo interaction - */ trackDemo: ( action: 'start' | 'step_complete' | 'source_click', demoName?: string, @@ -182,31 +274,26 @@ export function usePostHogTracking() { source_click: POSTHOG_EVENTS.DEMO_SOURCE_CLICK, } - posthog?.capture(eventNameMap[action], { + captureDocsEvent(eventNameMap[action], { [POSTHOG_PROPERTIES.DEMO_NAME]: demoName, [POSTHOG_PROPERTIES.DEMO_STEP]: step, [POSTHOG_PROPERTIES.DEMO_STEP_NAME]: stepName, - [POSTHOG_PROPERTIES.LINK_URL]: sourceUrl, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + [POSTHOG_PROPERTIES.LINK_URL]: sourceUrl ? sanitizeAnalyticsUrl(sourceUrl) : undefined, }) }, - /** - * Track a search query - */ - trackSearch: (query: string, resultTitle?: string, resultUrl?: string) => { - if (resultTitle || resultUrl) { - posthog?.capture(POSTHOG_EVENTS.SEARCH_RESULT_CLICK, { - [POSTHOG_PROPERTIES.SEARCH_QUERY]: query, - [POSTHOG_PROPERTIES.SEARCH_RESULT_TITLE]: resultTitle, - [POSTHOG_PROPERTIES.SEARCH_RESULT_URL]: resultUrl, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, + // Queries are reduced to length only. Search terms can contain secrets or + // customer information and are intentionally not retained. + trackSearch: (query: string, _resultTitle?: string, resultUrl?: string) => { + if (resultUrl) { + trackDocsSearchResultClick({ + queryLength: query.trim().length, + resultPath: new URL(resultUrl, window.location.origin).pathname, + resultRank: 0, + resultType: 'unknown', }) } else { - posthog?.capture(POSTHOG_EVENTS.SEARCH_QUERY, { - [POSTHOG_PROPERTIES.SEARCH_QUERY]: query, - [POSTHOG_PROPERTIES.PAGE_PATH]: window.location.pathname, - }) + trackDocsSearchOpened('header') } }, } diff --git a/src/marketing/MarketingRoute.tsx b/src/marketing/MarketingRoute.tsx index eee8e61e..da724180 100644 --- a/src/marketing/MarketingRoute.tsx +++ b/src/marketing/MarketingRoute.tsx @@ -73,12 +73,15 @@ export default function MarketingRoute({ head?: ReactNode }) { const [analyticsReady, setAnalyticsReady] = useState(false) + const [postHogReady, setPostHogReady] = useState(false) const resolvedMetadata = metadata ?? routeMetadata[route] ?? fallbackMetadata(route) useEffect(() => { return scheduleIdleAnalytics(() => setAnalyticsReady(true)) }, []) + useEffect(() => setPostHogReady(true), []) + useEffect(() => { const prefetchAnchor = (event: Event) => { const target = event.target @@ -102,13 +105,17 @@ export default function MarketingRoute({ {head} + {postHogReady && ( + + + + )} {children} {analyticsReady && ( - )} diff --git a/src/marketing/app/_components/Header.tsx b/src/marketing/app/_components/Header.tsx index 8861b0d8..31630d29 100644 --- a/src/marketing/app/_components/Header.tsx +++ b/src/marketing/app/_components/Header.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' import { AmpLogo, ClaudeLogo, CodexLogo } from '../../../components/AgentLogos' +import { trackDocsSearchOpened } from '../../../lib/posthog' import { developersPath } from '../_lib/developersPaths' import { featurePath } from '../_lib/featurePaths' import { TEMPO_SDK_DOCS_URL } from '../_lib/links' @@ -647,12 +648,13 @@ export default function Header() { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault() - setSearchOpen((s) => !s) + if (!searchOpen) trackDocsSearchOpened('keyboard') + setSearchOpen(!searchOpen) } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) - }, []) + }, [searchOpen]) const dropdowns: { key: string; panel: ReactNode }[] = [ ...menu.flatMap((item) => @@ -753,7 +755,10 @@ export default function Header() {