From c6df1a43fb11b8b20b7ce01df3cc9384b9204857 Mon Sep 17 00:00:00 2001 From: Juan Leal Date: Fri, 10 Jul 2026 20:30:23 -0400 Subject: [PATCH 1/4] Add growth funnel analytics to docs --- src/components/DocsHeader.tsx | 15 ++ src/components/PostHogSetup.tsx | 196 ++++++++++++-- src/lib/posthog-analytics.test.ts | 93 +++++++ src/lib/posthog-analytics.ts | 252 ++++++++++++++++++ src/lib/posthog.ts | 250 +++++++++-------- src/marketing/MarketingRoute.tsx | 4 +- src/marketing/app/_components/Header.tsx | 12 +- .../app/_components/SearchDialog.tsx | 10 +- src/marketing/main.tsx | 4 +- src/pages/docs/_layout.tsx | 4 +- 10 files changed, 695 insertions(+), 145 deletions(-) create mode 100644 src/lib/posthog-analytics.test.ts create mode 100644 src/lib/posthog-analytics.ts 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..faedc666 100644 --- a/src/components/PostHogSetup.tsx +++ b/src/components/PostHogSetup.tsx @@ -1,50 +1,198 @@ 'use client' +import posthog from 'posthog-js' import { useEffect } from 'react' +import { + markPostHogReady, + trackDocsCopyCode, + trackDocsCtaClick, + trackDocsSearchResultClick, +} from '../lib/posthog' +import { classifyStrategicCta, 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 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 || !posthogHost) return + if (!posthogKey) return - const init = async () => { - const { default: posthog } = await import('posthog-js') + const registerHumanContext = () => { + posthog.register({ site, traffic_type: 'human' }) + markPostHogReady() + } + if (posthog.__loaded) { + registerHumanContext() + } else { 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, session_recording: { - maskAllInputs: false, - maskInputOptions: { - password: true, - }, + maskAllInputs: true, }, + enable_recording_console_log: false, + before_send: sanitizePostHogCapture, + capture_exceptions: true, + debug: import.meta.env.MODE === 'development', + loaded: registerHumanContext, }) - posthog.register({ site }) } - 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 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) + } + + const button = target.closest('button') + if (!button) return + const metadata = copyMetadata(button) + if (metadata) trackDocsCopyCode(metadata) } - const timeoutId = globalThis.setTimeout(init, 1) - return () => globalThis.clearTimeout(timeoutId) + 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) + } + + document.addEventListener('click', handleClick, true) + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('click', handleClick, true) + document.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..acd53d85 --- /dev/null +++ b/src/lib/posthog-analytics.test.ts @@ -0,0 +1,93 @@ +import type { CaptureResult } from 'posthog-js' +import { describe, expect, it } from 'vitest' +import { + classifyAnalyticsPage, + classifyStrategicCta, + getAnalyticsPageSection, + 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=abc123') + }) + + 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') + }) +}) + +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', + $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', + $elements: [{ attr__href: '/docs/api?utm_campaign=launch' }], + }) + }) +}) + +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) + }) +}) + +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', + destinationCategory: 'contact', + conversionIntent: 'api_access', + }) + }) + + it('classifies high-value developer destinations', () => { + expect( + classifyStrategicCta('/developers/docs/quickstart/integrate-tempo', '/developers/build'), + ).toEqual({ + ctaId: 'integrate_tempo', + destinationCategory: 'get_started', + }) + }) + + it('does not classify ordinary documentation navigation as a CTA', () => { + expect(classifyStrategicCta('/docs/protocol', '/docs')).toBeNull() + }) +}) diff --git a/src/lib/posthog-analytics.ts b/src/lib/posthog-analytics.ts new file mode 100644 index 00000000..b4488cc8 --- /dev/null +++ b/src/lib/posthog-analytics.ts @@ -0,0 +1,252 @@ +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 blockedPropertyNames = new Set([ + 'api_key', + 'authorization', + 'code_snippet', + 'command_text', + 'email', + 'feedback_message', + 'password', + 'phone', + 'private_key', + 'search_query', +]) + +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, +] + +export type AnalyticsPageType = + | 'developer_blog' + | 'developer_feature' + | 'developer_home' + | 'developer_performance' + | 'docs' + | 'other' + +export type StrategicCta = { + ctaId: string + destinationCategory: string + conversionIntent?: string | undefined +} + +function shouldKeepQueryParameter(name: string) { + const normalized = name.toLowerCase() + return normalized.startsWith('utm_') || campaignQueryParameters.has(normalized) +} + +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 + + const isAbsolute = /^[a-z][a-z\d+.-]*:\/\//i.test(input) + const isProtocolRelative = input.startsWith('//') + const isRelativeUrl = input.startsWith('/') || input.startsWith('?') || input.startsWith('#') + if (!isAbsolute && !isProtocolRelative && !isRelativeUrl) return value + + try { + const url = new URL(input, 'https://tempo.xyz') + if (url.protocol !== 'http:' && url.protocol !== 'https:') return value + + url.username = '' + url.password = '' + url.hash = '' + url.pathname = redactSensitivePathSegments(url.pathname) + + for (const name of [...url.searchParams.keys()]) { + if (!shouldKeepQueryParameter(name)) { + url.searchParams.delete(name) + continue + } + + const values = url.searchParams.getAll(name) + url.searchParams.delete(name) + for (const item of values) + url.searchParams.append(name, item.slice(0, maxCampaignValueLength)) + } + + if (isAbsolute || isProtocolRelative) return url.toString() + return `${url.pathname}${url.search}` + } catch { + return value + } +} + +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 sanitizePropertyValue(name: string, value: unknown): unknown { + if (blockedPropertyNames.has(name.toLowerCase())) return undefined + 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' +} + +function contactIntent(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' +} + +/** 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 = url.hostname.toLowerCase().replace(/^www\./, '') + const path = normalizeAnalyticsPath(url.pathname) + + if (hostname === 'tempo.xyz' && /^\/contact\/?$/.test(url.pathname)) { + const conversionIntent = contactIntent(currentPathname) + return { + ctaId: `contact_${conversionIntent}`, + destinationCategory: 'contact', + conversionIntent, + } + } + + if (hostname === 'wallet.tempo.xyz') + return { ctaId: 'tempo_wallet', destinationCategory: 'wallet' } + if (hostname === 'faucet.tempo.xyz' || path === '/docs/quickstart/faucet') + return { ctaId: 'tempo_faucet', destinationCategory: 'faucet' } + if (hostname === 'explorer.tempo.xyz') + return { ctaId: 'tempo_explorer', destinationCategory: 'explorer' } + if (hostname === 'github.com' && url.pathname.toLowerCase().startsWith('/tempoxyz')) + return { ctaId: 'tempo_github', destinationCategory: 'source' } + if (hostname === 'mpp.dev') return { ctaId: 'mpp_docs', destinationCategory: 'mpp' } + + const strategicDocsPaths: Record = { + '/docs/guide/machine-payments': { + ctaId: 'machine_payments_guide', + destinationCategory: 'guide', + }, + '/docs/guide/payments/accept-a-payment': { + ctaId: 'accept_payments_guide', + destinationCategory: 'guide', + }, + '/docs/guide/using-tempo-with-ai': { + ctaId: 'tempo_ai_guide', + destinationCategory: 'agent_setup', + }, + '/docs/quickstart/integrate-tempo': { + ctaId: 'integrate_tempo', + destinationCategory: 'get_started', + }, + } + const strategicDocsCta = strategicDocsPaths[path] + if (hostname === 'tempo.xyz' && strategicDocsCta) return strategicDocsCta + + if (hostname === 'tempo.xyz' && path === '/') { + return { ctaId: 'tempo_marketing_site', destinationCategory: 'marketing' } + } + + return null + } catch { + return null + } +} diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 61d01c1b..570fad9e 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -1,174 +1,207 @@ 'use client' import posthog from 'posthog-js' +import { + classifyAnalyticsPage, + getAnalyticsPageSection, + normalizeAnalyticsPath, + 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', EXTERNAL_DOMAIN: 'external_domain', - - // Code-related properties 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 } + +const pendingEvents: PendingEvent[] = [] +const maxPendingEvents = 50 +let posthogReady = false + +function currentPageContext(): EventProperties { + if (typeof window === 'undefined') return {} + + const pagePath = normalizeAnalyticsPath(window.location.pathname) + return { + [POSTHOG_PROPERTIES.PAGE_PATH]: pagePath, + [POSTHOG_PROPERTIES.PAGE_TYPE]: classifyAnalyticsPage(pagePath), + [POSTHOG_PROPERTIES.PAGE_SECTION]: getAnalyticsPageSection(pagePath), + [POSTHOG_PROPERTIES.PAGE_TITLE]: typeof document === 'undefined' ? undefined : document.title, + } +} + +export function captureDocsEvent(event: string, properties: EventProperties = {}) { + if (typeof window === 'undefined') return + + const payload = { ...currentPageContext(), ...properties } + if (posthogReady && posthog.__loaded) { + posthog.capture(event, payload) + return + } + + if (pendingEvents.length >= maxPendingEvents) pendingEvents.shift() + pendingEvents.push({ event, properties: payload }) +} + +/** Called after PostHog has registered the human site context. */ +export function markPostHogReady() { + posthogReady = true + for (const pending of pendingEvents.splice(0)) { + posthog.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: { + ctaId: string + destinationCategory: string + conversionIntent?: string | undefined +}) { + captureDocsEvent(POSTHOG_EVENTS.CTA_CLICK, { + cta_id: properties.ctaId, + destination_category: properties.destinationCategory, + conversion_intent: properties.conversionIntent, + }) +} + +function safeLinkLabel(value?: string) { + return value?.trim().slice(0, 80) || undefined +} + export function usePostHogTracking() { return { posthog, - /** - * Track a page view - */ 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, + captureDocsEvent(POSTHOG_EVENTS.CTA_CLICK, { + [POSTHOG_PROPERTIES.BUTTON_TEXT]: safeLinkLabel(ctaText), + [POSTHOG_PROPERTIES.LINK_URL]: destination ? sanitizeAnalyticsUrl(destination) : undefined, }) }, - /** - * 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 +215,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..2fc3c70a 100644 --- a/src/marketing/MarketingRoute.tsx +++ b/src/marketing/MarketingRoute.tsx @@ -2,6 +2,7 @@ import { lazy, type ReactNode, Suspense, useEffect, useState } from 'react' import PageHead from '../components/PageHead' +import PostHogSetup from '../components/PostHogSetup' import { type RouteMetadata, routeMetadata } from './routeMetadata' const Analytics = lazy(() => @@ -11,7 +12,6 @@ const SpeedInsights = lazy(() => import('@vercel/speed-insights/react').then((module) => ({ default: module.SpeedInsights })), ) const GoogleAnalytics = lazy(() => import('../components/GoogleAnalytics')) -const PostHogSetup = lazy(() => import('../components/PostHogSetup')) const prefetchedPaths = new Set() const ANALYTICS_DELAY_MS = 15_000 @@ -102,13 +102,13 @@ export default function MarketingRoute({ {head} + {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() {