diff --git a/ANALYTICS.md b/ANALYTICS.md index 0daefb0..f795073 100644 --- a/ANALYTICS.md +++ b/ANALYTICS.md @@ -1,8 +1,10 @@ # Platform-managed analytics contract -Every official template ships with PostHog analytics enabled for production builds. InsForge creates -one PostHog project per application and injects the public project token at runtime. Template users -do not create a PostHog account and templates must never contain a `phx_` personal API key. +Every official template ships with Google Analytics 4 (GA4) enabled for production builds. +InsForge creates one GA4 property and web data stream per application, then injects its public +measurement ID at runtime. Template users do not need to create a Google Analytics account, and +templates must never contain Google service-account credentials or a Measurement Protocol API +secret. ## Runtime configuration @@ -11,8 +13,7 @@ before the application bundle so it can inject `window.__INSFORGE_RUNTIME_CONFIG ```ts { - posthogHost: 'https://us.i.posthog.com', - posthogProjectToken: 'phc_...', + gaMeasurementId: 'G-XXXXXXXXXX', applicationId: '...', environmentId: '...', templateVersionId: '...', @@ -20,9 +21,9 @@ before the application bundle so it can inject `window.__INSFORGE_RUNTIME_CONFIG } ``` -The analytics helper prefers this object and falls back to the documented `NEXT_PUBLIC_*` or -`VITE_*` variables for standalone deployments. Missing configuration and non-production builds are -safe no-ops. +The analytics helper prefers this object and falls back to `NEXT_PUBLIC_GA_MEASUREMENT_ID` or +`VITE_GA_MEASUREMENT_ID` for standalone deployments. Missing configuration and non-production +builds are safe no-ops. A measurement ID is public; Google credentials remain platform-only. ## Event rules @@ -30,20 +31,27 @@ Import `analytics` from the template's `lib/analytics` module. Use its semantic fit, or `analytics.track('domain_event', { safe_property: value })`. Conversion events must fire only after the operation succeeds; payments must include their stable transaction ID. -Every template automatically captures page views, page leaves, and web performance measurements. -The helper also provides `cta_clicked`, `form_started`, `form_submitted`, `sign_up_completed`, -`login_completed`, and `purchase_completed`, but templates must wire those semantic events into -their own successful business operations. Every event is registered with application, -environment, template-version, and release context. +Every template sends an initial `page_view` and another privacy-safe page view when a Next.js or +Vite SPA route changes. Page locations contain only origin and pathname; query strings and URL +fragments are never sent. The platform disables enhanced measurement's automatic browser-history +page changes on managed streams, preventing these manual SPA page views from being counted twice. +The semantic helpers emit GA4-compatible events: `cta_click`, `form_start`, `form_submit`, +`generate_lead`, `sign_up`, `login`, and `purchase`. `formSubmitted` is an ordinary completed form; +use `leadSubmitted` only for an actual sales/contact lead because `generate_lead` is a Key Event. +Legacy event names passed to `track` are mapped to the corresponding non-conversion or recommended +event names. Every event also +receives application, environment, template-version, and release context through the shared GA4 +event context. Never send names, emails, phone numbers, addresses, credentials, access tokens, form bodies, prompts, message text, filenames, or raw URLs containing query parameters. `identify` accepts only -the application's opaque user ID and no person properties. The helper drops common PII property -keys and email-shaped values as a final guard, but this does not replace careful event design. - -Session replay defaults to a stable 10% per-session sample, masks all inputs and personal-data -properties, and masks/blocks elements marked -`data-private`. Add `data-private` to any region that renders customer content. Autocapture is off, -so product events remain stable when markup or copy changes. - -Run `node scripts/check-analytics.mjs` before publishing a template. +the application's opaque user ID and no user properties. The helper drops common PII property keys +and email-shaped values as a final guard, but this does not replace careful event design. +For privacy-first behavior, keys containing a delimited `name`, `content`, or `query` token are also +blocked (for example `template_name`, `content_type`, and `query_length`), even when a particular +value would not contain PII. + +GA4 does not provide session replay, and official templates do not load a replay SDK or capture DOM +content. Google's tag host may be unavailable from mainland China; deployments targeting mainland +users must validate outbound reachability and should expect zero events when it is blocked. Run +`node scripts/check-analytics.mjs` before publishing a template. diff --git a/README.md b/README.md index fae1ccd..e7af685 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Not standalone templates. These are scaffolds the CLI overlays on top of an exis - Framework starters and app templates built with [Next.js](https://nextjs.org), [React](https://react.dev), and [Vite](https://vite.dev) - UI foundations built with [Tailwind CSS](https://tailwindcss.com) across the repository - Authentication, database, and storage integration with [InsForge](https://insforge.dev) -- Platform-managed PostHog analytics with a shared [event and privacy contract](./ANALYTICS.md) +- Platform-managed GA4 analytics with a shared [event and privacy contract](./ANALYTICS.md) - Per-template setup guides and example environment variables for local development - Deployment paths designed to work well with [Vercel](https://vercel.com) - Templates designed to be adapted into real products diff --git a/admin-dashboard/.env.example b/admin-dashboard/.env.example index 39b5a61..76357bd 100644 --- a/admin-dashboard/.env.example +++ b/admin-dashboard/.env.example @@ -2,9 +2,7 @@ VITE_INSFORGE_URL=https://your-appkey.region.insforge.app VITE_INSFORGE_ANON_KEY=your-anon-key # Platform-managed analytics (production only) -VITE_POSTHOG_PROJECT_TOKEN=phc_project_token -VITE_POSTHOG_HOST=https://us.i.posthog.com -VITE_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +VITE_GA_MEASUREMENT_ID= VITE_INSFORGE_APP_ID= VITE_INSFORGE_ENVIRONMENT_ID= VITE_INSFORGE_TEMPLATE_ID=admin-dashboard diff --git a/admin-dashboard/package-lock.json b/admin-dashboard/package-lock.json index 1b7a993..829eed6 100644 --- a/admin-dashboard/package-lock.json +++ b/admin-dashboard/package-lock.json @@ -37,7 +37,6 @@ "input-otp": "^1.4.2", "lucide-react": "^0.474.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.76.1", @@ -991,31 +990,6 @@ "node": ">= 8" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -3233,13 +3207,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3534,20 +3501,6 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3758,15 +3711,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -3909,12 +3853,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4518,54 +4456,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/prettier": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", @@ -4599,12 +4489,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5532,19 +5416,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json index edb34d1..00cd1f8 100644 --- a/admin-dashboard/package.json +++ b/admin-dashboard/package.json @@ -43,7 +43,6 @@ "input-otp": "^1.4.2", "lucide-react": "^0.474.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.76.1", diff --git a/admin-dashboard/src/lib/analytics.ts b/admin-dashboard/src/lib/analytics.ts index dab5de9..f6e4069 100644 --- a/admin-dashboard/src/lib/analytics.ts +++ b/admin-dashboard/src/lib/analytics.ts @@ -1,15 +1,19 @@ -import posthog from 'posthog-js' - export type AnalyticsValue = string | number | boolean | null | undefined export type AnalyticsProperties = Record const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer'] +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +} type RuntimeConfig = { - posthogHost?: string - posthogProjectToken?: string + gaMeasurementId?: string applicationId?: string environmentId?: string templateVersionId?: string @@ -19,98 +23,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false - return typeof value !== 'string' || !EMAIL_VALUE.test(value) - }), - ) as Record +let measurementId: string | undefined +let lastPagePath: string | undefined +let analyticsContext: Record = {} + +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue + if (typeof value !== 'string') { + sanitized[key] = value + continue + } + if (EMAIL_VALUE.test(value)) continue + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value) + sanitized[key] = `${url.origin}${url.pathname}` + continue + } catch { + continue + } + } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value + } + return sanitized } -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties } - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key] +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, + template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, + environment: 'production', } - for (const key of URL_KEYS) { - const value = sanitized[key] - if (typeof value !== 'string') continue - try { - const url = new URL(value) - url.search = '' - url.hash = '' - sanitized[key] = url.toString() - } catch { - delete sanitized[key] +} + +function pagePath(): string { + return window.location.pathname || '/' +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return + const path = pagePath() + if (path === lastPagePath) return + lastPagePath = path + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }) +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView) + } else { + window.setTimeout(trackPageView, 0) } } - return sanitized + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method] + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args) + notify() + return result + } as History[typeof method] + } + window.addEventListener('popstate', notify) } export function initializeAnalytics(): void { const runtime = window.__INSFORGE_RUNTIME_CONFIG__ - const token = runtime?.posthogProjectToken || import.meta.env.VITE_POSTHOG_PROJECT_TOKEN - if (!import.meta.env.PROD || !token || posthog.__loaded) return - - const configuredRate = Number(import.meta.env.VITE_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1') - const replaySampleRate = Number.isFinite(configuredRate) ? Math.min(1, Math.max(0, configuredRate)) : 0.1 - - posthog.init(token, { - api_host: runtime?.posthogHost || import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, - template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ) - }, - }) + const configuredId = runtime?.gaMeasurementId || import.meta.env.VITE_GA_MEASUREMENT_ID + if (!import.meta.env.PROD || !configuredId || measurementId) return + + measurementId = configuredId + window.dataLayer = window.dataLayer || [] + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)) + window.gtag('js', new Date()) + analyticsContext = safeProperties(commonProperties(runtime)) + window.gtag('set', analyticsContext) + window.gtag('config', measurementId, { send_page_view: false }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}` + script.dataset.insforgeAnalytics = 'ga4' + document.head.appendChild(script) + + installNavigationTracking() + trackPageView() } export function track(event: string, properties?: AnalyticsProperties): void { - if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) + if (!measurementId || !window.gtag) return + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }) } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (posthog.__loaded && userId && !EMAIL_VALUE.test(userId)) posthog.identify(userId) + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }) } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset() + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }) + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), } diff --git a/ai-pdf-chatbot/.env.example b/ai-pdf-chatbot/.env.example index 0f4d63d..1276531 100644 --- a/ai-pdf-chatbot/.env.example +++ b/ai-pdf-chatbot/.env.example @@ -25,9 +25,7 @@ INSFORGE_JWT_SECRET=replace-with-output-of-cli-secrets-get-JWT_SECRET OPENAI_API_KEY= # ─── Platform-managed analytics (production only) ───────────────────── -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN= -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=ai-pdf-chatbot diff --git a/ai-pdf-chatbot/app/api/chat/route.ts b/ai-pdf-chatbot/app/api/chat/route.ts index dd48ea8..264d8fa 100644 --- a/ai-pdf-chatbot/app/api/chat/route.ts +++ b/ai-pdf-chatbot/app/api/chat/route.ts @@ -5,7 +5,6 @@ import { retrieveForQuestion, toCitations } from '@/lib/rag/retrieve'; import { RAG_SYSTEM_PROMPT, buildUserPrompt } from '@/lib/ai/prompts'; import { CHAT_MODEL } from '@/lib/ai/constants'; import { encodeNdjson } from '@/lib/stream/ndjson'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -100,22 +99,6 @@ export async function POST(req: Request) { const resolvedChatId = chatId; const inputText = body.input.trim(); const docIds = body.documentIds; - const isNewChat = !body.chatId; - - const posthog = getPostHogClient(); - posthog.capture({ - distinctId: ownerId, - event: 'chat_message_sent', - properties: { - chat_id: resolvedChatId, - workspace_id: workspaceId, - is_new_chat: isNewChat, - }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - const stream = new ReadableStream({ async start(controller) { const send = (obj: unknown) => controller.enqueue(encodeNdjson(obj)); diff --git a/ai-pdf-chatbot/app/api/documents/upload/route.ts b/ai-pdf-chatbot/app/api/documents/upload/route.ts index ceae58f..9fc2fa3 100644 --- a/ai-pdf-chatbot/app/api/documents/upload/route.ts +++ b/ai-pdf-chatbot/app/api/documents/upload/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'; import { createInsforgeServerClient } from '@/lib/insforge'; import { getCurrentAuthState } from '@/lib/auth-state'; import { ingestPdf } from '@/lib/rag/ingest'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -87,22 +86,6 @@ export async function POST(req: Request) { buffer, }); - const posthog = getPostHogClient(); - posthog.capture({ - distinctId: auth.viewer.id, - event: 'document_uploaded', - properties: { - document_id: doc.id, - file_size_bytes: file.size, - workspace_id: workspaceId, - ingest_status: ingestResult.status, - chunk_count: ingestResult.status === 'ready' ? ingestResult.chunkCount : 0, - }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ document: { id: doc.id, file_name: file.name, file_size: file.size, status: ingestResult.status }, ingest: ingestResult, diff --git a/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts b/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts index ad2c95a..4f59bf6 100644 --- a/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts +++ b/ai-pdf-chatbot/app/api/flashcards/[id]/grade/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'; import { createInsforgeServerClient } from '@/lib/insforge'; import { getCurrentAuthState } from '@/lib/auth-state'; import { schedule, type Grade } from '@/lib/srs/schedule'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -55,21 +54,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); - const posthog = getPostHogClient(); - posthog.capture({ - distinctId: auth.viewer.id ?? 'anonymous', - event: 'flashcard_graded', - properties: { - card_id: id, - grade, - next_interval_days: next.interval_days, - reps: next.reps, - }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ ok: true, next: { diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts index 0b493cb..6e2e432 100644 --- a/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts @@ -3,7 +3,6 @@ import { createInsforgeServerClient } from '@/lib/insforge'; import { getCurrentAuthState } from '@/lib/auth-state'; import { generateAudioScript } from '@/lib/ai/audio-script'; import { synthesizeScript } from '@/lib/audio/tts'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -99,15 +98,5 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st .eq('id', id); if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); - const posthog = getPostHogClient(); - posthog.capture({ - distinctId: auth.viewer.id, - event: 'audio_overview_generated', - properties: { workspace_id: id, document_count: docs.length, script_turn_count: script.length }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ audio_url: audioUrl, script, generated_at: now }); } diff --git a/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts b/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts index c83a6a6..d56f81b 100644 --- a/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from 'next/server'; import { createInsforgeServerClient } from '@/lib/insforge'; import { getCurrentAuthState } from '@/lib/auth-state'; import { UTILITY_MODEL } from '@/lib/ai/constants'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -91,15 +90,5 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st .eq('id', id); if (upd.error) return NextResponse.json({ error: upd.error.message }, { status: 500 }); - const posthog = getPostHogClient(); - posthog.capture({ - distinctId: auth.viewer.id ?? 'anonymous', - event: 'mindmap_generated', - properties: { workspace_id: id, document_count: docs.length }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ markdown, generated_at: now }); } diff --git a/ai-pdf-chatbot/app/api/workspaces/route.ts b/ai-pdf-chatbot/app/api/workspaces/route.ts index d2cb0fc..41bcfe3 100644 --- a/ai-pdf-chatbot/app/api/workspaces/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; import { createInsforgeServerClient } from '@/lib/insforge'; import { getCurrentAuthState } from '@/lib/auth-state'; -import { getPostHogClient } from '@/lib/posthog-server'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -50,16 +49,5 @@ export async function POST(req: Request) { if (error || !data) return NextResponse.json({ error: error?.message ?? 'Insert failed' }, { status: 500 }); - const posthog = getPostHogClient(); - const ws = data as { id: string }; - posthog.capture({ - distinctId: auth.viewer.id, - event: 'workspace_created', - properties: { workspace_id: ws.id }, - }); - // Analytics flush failure must not surface as a 500 for the user; - // the write above already succeeded. Swallow the error. - await posthog.shutdown().catch(() => undefined); - return NextResponse.json({ workspace: data }); } diff --git a/ai-pdf-chatbot/components/flashcards-modal.tsx b/ai-pdf-chatbot/components/flashcards-modal.tsx index 18c7ac5..58fcbcf 100644 --- a/ai-pdf-chatbot/components/flashcards-modal.tsx +++ b/ai-pdf-chatbot/components/flashcards-modal.tsx @@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Sparkles, X } from 'luci import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import posthog from 'posthog-js'; +import { analytics } from '@/lib/analytics'; type Card = { id: string; question: string; answer: string; sort_order: number }; @@ -54,7 +54,7 @@ export function FlashcardsModal({ setCards(cards); setIndex(0); setRevealed(false); - posthog.capture('flashcards_generated', { document_id: documentId, card_count: cards.length }); + analytics.track('flashcards_generated', { document_id: documentId, card_count: cards.length }); } catch { toast.error('Failed to generate flashcards'); } finally { diff --git a/ai-pdf-chatbot/components/share-chat-button.tsx b/ai-pdf-chatbot/components/share-chat-button.tsx index 58e4a25..26b7810 100644 --- a/ai-pdf-chatbot/components/share-chat-button.tsx +++ b/ai-pdf-chatbot/components/share-chat-button.tsx @@ -4,7 +4,7 @@ import { Check, Copy, Link2, Loader2, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import posthog from 'posthog-js'; +import { analytics } from '@/lib/analytics'; export function ShareChatButton({ chatId, @@ -58,9 +58,9 @@ export function ShareChatButton({ setShareToken(next); onShareTokenChange?.(next); if (enable) { - posthog.capture('chat_share_link_created', { chat_id: chatId }); + analytics.track('chat_share_link_created', { chat_id: chatId }); } else { - posthog.capture('chat_share_link_revoked', { chat_id: chatId }); + analytics.track('chat_share_link_revoked', { chat_id: chatId }); } toast.success(enable ? 'Share link created' : 'Share link revoked'); } catch { diff --git a/ai-pdf-chatbot/lib/analytics.ts b/ai-pdf-chatbot/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/ai-pdf-chatbot/lib/analytics.ts +++ b/ai-pdf-chatbot/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/ai-pdf-chatbot/lib/posthog-server.ts b/ai-pdf-chatbot/lib/posthog-server.ts deleted file mode 100644 index 22d51d4..0000000 --- a/ai-pdf-chatbot/lib/posthog-server.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PostHog } from 'posthog-node'; - -const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query|file_name)(_|$)/i; - -export function getPostHogClient(): PostHog { - const token = process.env.POSTHOG_PROJECT_TOKEN || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN || ''; - return new PostHog(token, { - host: process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST, - disabled: process.env.NODE_ENV !== 'production' || !token, - flushAt: 1, - flushInterval: 0, - before_send(event) { - if (!event) return null; - const properties = Object.fromEntries( - Object.entries(event.properties ?? {}).filter(([key]) => !PII_KEY.test(key)), - ); - return { - ...event, - properties: { - ...properties, - application_id: process.env.INSFORGE_APPLICATION_ID, - environment_id: process.env.INSFORGE_ENVIRONMENT_ID, - template_version_id: process.env.INSFORGE_TEMPLATE_VERSION_ID, - release_id: process.env.INSFORGE_RELEASE_ID, - }, - }; - }, - }); -} diff --git a/ai-pdf-chatbot/package-lock.json b/ai-pdf-chatbot/package-lock.json index 3cf1ede..d8db07c 100644 --- a/ai-pdf-chatbot/package-lock.json +++ b/ai-pdf-chatbot/package-lock.json @@ -21,8 +21,6 @@ "next-themes": "^0.4.6", "pdfjs-dist": "^5.7.284", "pg": "^8.13.0", - "posthog-js": "1.425.1", - "posthog-node": "^5.36.8", "react": "19.2.0", "react-dom": "19.2.0", "react-pdf": "^10.4.1", @@ -1563,46 +1561,6 @@ "node": ">=14" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.2.tgz", - "integrity": "sha512-ukDmETXy6fTBE4ZpaO8ccscrNz5SvTaUnhQ0Ze2JX/oYILa89rrNwqUH7lDAuhFKa+NY6EoJ9kZMuOvuu4WwLQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.4", - "@posthog/types": "^1.408.1" - } - }, - "node_modules/@posthog/browser-common/node_modules/@posthog/core": { - "version": "1.50.4", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.4.tgz", - "integrity": "sha512-4iuCMbPJgy+w8xiFlSM/zapchVSzkba6Nb+iZEo+4DsAplNFj38pXE1htGx2GU8Fac70PPOwvNv4uHsZjYDbzA==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.408.1" - } - }, - "node_modules/@posthog/browser-common/node_modules/@posthog/types": { - "version": "1.408.1", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.1.tgz", - "integrity": "sha512-zcQ3rdAbWDWegL/TA3OiC+Wc8qquNtFj2Q2RPc3QuJas3D6FYsNhR9xYmZgLWEm+GDN/zhGlOHijG3nPMEiCbw==", - "license": "MIT" - }, - "node_modules/@posthog/core": { - "version": "1.30.14", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.30.14.tgz", - "integrity": "sha512-cC0che/17kP6qMIMgdmxsoz3h8Jar8knQfDM8WqQwVacSeWXkrwkemoV7S5tCGmgTuRTTsdigirs9HiBXHQ/dA==", - "license": "MIT", - "dependencies": { - "@posthog/types": "1.383.3" - } - }, - "node_modules/@posthog/types": { - "version": "1.383.3", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.383.3.tgz", - "integrity": "sha512-N4jtmLaJxzjQ/C0UHnF0igQPSwUqwScPDv9ePGjKCfDomIEUcO3+c6pBrjTp7woxuMQ49BmyM/pV4/SOBPpe0Q==", - "license": "MIT" - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -2087,13 +2045,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@vscode/markdown-it-katex": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@vscode/markdown-it-katex/-/markdown-it-katex-1.1.2.tgz", @@ -2498,17 +2449,6 @@ "node": ">= 12" } }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -3046,15 +2986,6 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -3181,12 +3112,6 @@ "@esbuild/win32-x64": "0.28.0" } }, - "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", - "license": "MIT" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4243,89 +4168,6 @@ "node": ">=0.10.0" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/posthog-js/node_modules/@posthog/core": { - "version": "1.50.4", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.4.tgz", - "integrity": "sha512-4iuCMbPJgy+w8xiFlSM/zapchVSzkba6Nb+iZEo+4DsAplNFj38pXE1htGx2GU8Fac70PPOwvNv4uHsZjYDbzA==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.408.1" - } - }, - "node_modules/posthog-js/node_modules/@posthog/types": { - "version": "1.408.1", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.1.tgz", - "integrity": "sha512-zcQ3rdAbWDWegL/TA3OiC+Wc8qquNtFj2Q2RPc3QuJas3D6FYsNhR9xYmZgLWEm+GDN/zhGlOHijG3nPMEiCbw==", - "license": "MIT" - }, - "node_modules/posthog-node": { - "version": "5.36.8", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.36.8.tgz", - "integrity": "sha512-xBdJ3Y5zcveN1QINN39dIiZiCbEhfLeh/4qBiICoLLQOTYfat6zLlwfBmFPcr4hdyQ1nBXh8sIQ9KzSG/zcxpA==", - "license": "MIT", - "dependencies": { - "@posthog/core": "1.30.14" - }, - "engines": { - "node": "^20.20.0 || >=22.22.0" - }, - "peerDependencies": { - "rxjs": "^7.0.0" - }, - "peerDependenciesMeta": { - "rxjs": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4344,12 +4186,6 @@ "node": ">=6" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -4725,19 +4561,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/ai-pdf-chatbot/package.json b/ai-pdf-chatbot/package.json index d6f9cad..6e41f21 100644 --- a/ai-pdf-chatbot/package.json +++ b/ai-pdf-chatbot/package.json @@ -25,8 +25,6 @@ "next-themes": "^0.4.6", "pdfjs-dist": "^5.7.284", "pg": "^8.13.0", - "posthog-js": "1.425.1", - "posthog-node": "^5.36.8", "react": "19.2.0", "react-dom": "19.2.0", "react-pdf": "^10.4.1", diff --git a/booking/.env.example b/booking/.env.example index b52df36..b9d575a 100644 --- a/booking/.env.example +++ b/booking/.env.example @@ -3,9 +3,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key NEXT_PUBLIC_APP_URL=http://localhost:3000 # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=booking diff --git a/booking/lib/analytics.ts b/booking/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/booking/lib/analytics.ts +++ b/booking/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/booking/package-lock.json b/booking/package-lock.json index dc7827e..cbbc178 100644 --- a/booking/package-lock.json +++ b/booking/package-lock.json @@ -16,7 +16,6 @@ "date-fns-tz": "^3.2.0", "lucide-react": "^0.446.0", "next": "16.2.0", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", @@ -788,31 +787,6 @@ "node": ">= 10" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1117,6 +1091,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -1195,13 +1235,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -1261,20 +1294,6 @@ "node": ">=6" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1328,15 +1347,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/engine.io-client": { "version": "6.6.5", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", @@ -1373,12 +1383,6 @@ "node": ">=10.13.0" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1828,60 +1832,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -2101,19 +2051,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/booking/package.json b/booking/package.json index bcbb48f..6522406 100644 --- a/booking/package.json +++ b/booking/package.json @@ -18,7 +18,6 @@ "date-fns-tz": "^3.2.0", "lucide-react": "^0.446.0", "next": "16.2.0", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", diff --git a/chatbot/.env.example b/chatbot/.env.example index 303ccc7..c28e37b 100644 --- a/chatbot/.env.example +++ b/chatbot/.env.example @@ -5,9 +5,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key # INSFORGE_AI_MODEL=provider/model # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=chatbot diff --git a/chatbot/lib/analytics.ts b/chatbot/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/chatbot/lib/analytics.ts +++ b/chatbot/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/chatbot/package-lock.json b/chatbot/package-lock.json index 2220ae2..fa443a7 100644 --- a/chatbot/package-lock.json +++ b/chatbot/package-lock.json @@ -16,7 +16,6 @@ "lucide-react": "^0.446.0", "next": "16.3.1", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", @@ -823,31 +822,6 @@ "node": ">= 10" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1282,13 +1256,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/baseline-browser-mapping": { "version": "2.10.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", @@ -1348,20 +1315,6 @@ "node": ">=6" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1406,15 +1359,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/engine.io-client": { "version": "6.6.6", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", @@ -1451,12 +1395,6 @@ "node": ">=10.13.0" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1875,60 +1813,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -2153,19 +2037,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/chatbot/package.json b/chatbot/package.json index c84f9bd..85390f3 100644 --- a/chatbot/package.json +++ b/chatbot/package.json @@ -17,7 +17,6 @@ "lucide-react": "^0.446.0", "next": "16.3.1", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", diff --git a/crm/.env.example b/crm/.env.example index ba0902d..3af1857 100644 --- a/crm/.env.example +++ b/crm/.env.example @@ -3,9 +3,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key NEXT_PUBLIC_APP_URL=https://your-project.insforge.site # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=crm diff --git a/crm/lib/analytics.ts b/crm/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/crm/lib/analytics.ts +++ b/crm/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/crm/package-lock.json b/crm/package-lock.json index 3e5a152..1969641 100644 --- a/crm/package-lock.json +++ b/crm/package-lock.json @@ -29,7 +29,6 @@ "lucide-react": "^0.446.0", "next": "16.2.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", @@ -865,31 +864,6 @@ "node": ">= 10" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2319,6 +2293,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -2397,13 +2435,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2481,20 +2512,6 @@ "node": ">=6" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -2554,15 +2571,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -2599,12 +2607,6 @@ "node": ">=10.13.0" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3073,60 +3075,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -3508,19 +3456,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/crm/package.json b/crm/package.json index 8934d96..fc76b15 100644 --- a/crm/package.json +++ b/crm/package.json @@ -30,7 +30,6 @@ "lucide-react": "^0.446.0", "next": "16.2.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", diff --git a/e-commerce/.env.example b/e-commerce/.env.example index 74a32a6..a787360 100644 --- a/e-commerce/.env.example +++ b/e-commerce/.env.example @@ -3,9 +3,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key NEXT_PUBLIC_APP_URL=http://localhost:3000 # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=e-commerce diff --git a/e-commerce/README.md b/e-commerce/README.md index c71d771..f7ad70a 100644 --- a/e-commerce/README.md +++ b/e-commerce/README.md @@ -14,7 +14,7 @@ This starter includes a public storefront, seeded catalog data, customer authent - Variant-aware product pages and customer shopping flow - Authenticated cart, checkout, saved addresses, and order history - [InsForge](https://insforge.dev) authentication, database, storage, and Row Level Security -- Platform-managed PostHog analytics for traffic, conversions, retention, and privacy-safe replay +- Platform-managed GA4 analytics for traffic, conversions, acquisition, and retention - Built with [Next.js](https://nextjs.org), React 19, and [Tailwind CSS](https://tailwindcss.com) - Real Stripe Checkout with Apple Pay, Google Pay, and Link via InsForge Payments - Promotion codes redeemable at Stripe Checkout (configured in your Stripe dashboard, see Stripe setup section) @@ -172,8 +172,8 @@ After the migration runs, start by opening the home page, browsing a category, a ## Analytics -Production deployments use the platform-managed PostHog project automatically. Page views and -page leaves require no application code. Import `analytics` from `@/lib/analytics` for explicit +Production deployments use the platform-managed GA4 property automatically. Page views and SPA +route changes require no application code. Import `analytics` from `@/lib/analytics` for explicit business conversions such as a successful checkout. See [`../ANALYTICS.md`](../ANALYTICS.md) for the event and privacy contract. diff --git a/e-commerce/lib/analytics.ts b/e-commerce/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/e-commerce/lib/analytics.ts +++ b/e-commerce/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/e-commerce/package-lock.json b/e-commerce/package-lock.json index 3505f5e..834e6c8 100644 --- a/e-commerce/package-lock.json +++ b/e-commerce/package-lock.json @@ -15,7 +15,6 @@ "date-fns": "^4.1.0", "lucide-react": "^0.446.0", "next": "16.2.0", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", @@ -727,31 +726,6 @@ "node": ">= 10" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1044,6 +1018,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -1122,13 +1160,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -1188,20 +1219,6 @@ "node": ">=6" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1246,15 +1263,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -1291,12 +1299,6 @@ "node": ">=10.13.0" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1734,60 +1736,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -2007,19 +1955,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/e-commerce/package.json b/e-commerce/package.json index 91bdcae..7f4946e 100644 --- a/e-commerce/package.json +++ b/e-commerce/package.json @@ -16,7 +16,6 @@ "date-fns": "^4.1.0", "lucide-react": "^0.446.0", "next": "16.2.0", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", diff --git a/insight-flow-agent-chat/.env.example b/insight-flow-agent-chat/.env.example index ad07f77..4d95d81 100644 --- a/insight-flow-agent-chat/.env.example +++ b/insight-flow-agent-chat/.env.example @@ -2,9 +2,7 @@ VITE_INSFORGE_URL= VITE_INSFORGE_ANON_KEY= # Optional local/standalone fallback. Platform deployments inject these at runtime. -VITE_POSTHOG_PROJECT_TOKEN= -VITE_POSTHOG_HOST=https://us.i.posthog.com -VITE_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +VITE_GA_MEASUREMENT_ID= VITE_INSFORGE_APP_ID= VITE_INSFORGE_ENVIRONMENT_ID= VITE_INSFORGE_TEMPLATE_ID= diff --git a/insight-flow-agent-chat/package-lock.json b/insight-flow-agent-chat/package-lock.json index 4a085c9..cf919ce 100644 --- a/insight-flow-agent-chat/package-lock.json +++ b/insight-flow-agent-chat/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@insforge/sdk": "^1.5.2", "lucide-react": "^0.468.0", - "posthog-js": "1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -896,31 +895,6 @@ "node": ">= 8" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.2.tgz", - "integrity": "sha512-ukDmETXy6fTBE4ZpaO8ccscrNz5SvTaUnhQ0Ze2JX/oYILa89rrNwqUH7lDAuhFKa+NY6EoJ9kZMuOvuu4WwLQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.4", - "@posthog/types": "^1.408.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.4", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.4.tgz", - "integrity": "sha512-4iuCMbPJgy+w8xiFlSM/zapchVSzkba6Nb+iZEo+4DsAplNFj38pXE1htGx2GU8Fac70PPOwvNv4uHsZjYDbzA==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.408.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.1", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.1.tgz", - "integrity": "sha512-zcQ3rdAbWDWegL/TA3OiC+Wc8qquNtFj2Q2RPc3QuJas3D6FYsNhR9xYmZgLWEm+GDN/zhGlOHijG3nPMEiCbw==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1488,13 +1462,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1955,20 +1922,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2054,15 +2007,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.415", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", @@ -2249,12 +2193,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3820,54 +3758,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -3878,12 +3768,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4870,19 +4754,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/insight-flow-agent-chat/package.json b/insight-flow-agent-chat/package.json index f698333..f6d44d9 100644 --- a/insight-flow-agent-chat/package.json +++ b/insight-flow-agent-chat/package.json @@ -13,7 +13,6 @@ "dependencies": { "@insforge/sdk": "^1.5.2", "lucide-react": "^0.468.0", - "posthog-js": "1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/insight-flow-agent-chat/src/lib/analytics.ts b/insight-flow-agent-chat/src/lib/analytics.ts index 051d123..f6e4069 100644 --- a/insight-flow-agent-chat/src/lib/analytics.ts +++ b/insight-flow-agent-chat/src/lib/analytics.ts @@ -1,124 +1,163 @@ -import posthog from 'posthog-js'; +export type AnalyticsValue = string | number | boolean | null | undefined +export type AnalyticsProperties = Record -export type AnalyticsValue = string | number | boolean | null | undefined; -export type AnalyticsProperties = Record; - -const PII_KEY = - /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; -const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i +const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +} type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; - applicationId?: string; - environmentId?: string; - templateVersionId?: string; - releaseId?: string; -}; + gaMeasurementId?: string + applicationId?: string + environmentId?: string + templateVersionId?: string + releaseId?: string +} declare global { interface Window { - __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean } } -function safeProperties( - properties: AnalyticsProperties = {}, -): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; +let measurementId: string | undefined +let lastPagePath: string | undefined +let analyticsContext: Record = {} + +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue + if (typeof value !== 'string') { + sanitized[key] = value + continue + } + if (EMAIL_VALUE.test(value)) continue + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value) + sanitized[key] = `${url.origin}${url.pathname}` + continue + } catch { + continue + } + } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value + } + return sanitized } -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, + template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, + environment: 'production', } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +} + +function pagePath(): string { + return window.location.pathname || '/' +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return + const path = pagePath() + if (path === lastPagePath) return + lastPagePath = path + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }) +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView) + } else { + window.setTimeout(trackPageView, 0) } } - return sanitized; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method] + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args) + notify() + return result + } as History[typeof method] + } + window.addEventListener('popstate', notify) } export function initializeAnalytics(): void { - const runtime = window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || import.meta.env.VITE_POSTHOG_PROJECT_TOKEN; - if (!import.meta.env.PROD || !token || posthog.__loaded) return; - - const configuredRate = Number(import.meta.env.VITE_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: - runtime?.posthogHost || import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, - template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, - template_version_id: - runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const runtime = window.__INSFORGE_RUNTIME_CONFIG__ + const configuredId = runtime?.gaMeasurementId || import.meta.env.VITE_GA_MEASUREMENT_ID + if (!import.meta.env.PROD || !configuredId || measurementId) return + + measurementId = configuredId + window.dataLayer = window.dataLayer || [] + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)) + window.gtag('js', new Date()) + analyticsContext = safeProperties(commonProperties(runtime)) + window.gtag('set', analyticsContext) + window.gtag('config', measurementId, { send_page_view: false }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}` + script.dataset.insforgeAnalytics = 'ga4' + document.head.appendChild(script) + + installNavigationTracking() + trackPageView() } export function track(event: string, properties?: AnalyticsProperties): void { - if (posthog.__loaded) posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }) } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (posthog.__loaded && userId && !EMAIL_VALUE.test(userId)) posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }) } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }) + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => - track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), -}; + track('purchase', { transaction_id: transactionId, value, currency }), +} diff --git a/landing/.env.example b/landing/.env.example index 8a84a07..eacc52c 100644 --- a/landing/.env.example +++ b/landing/.env.example @@ -7,9 +7,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_BRAND_NAME=Acme # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=landing diff --git a/landing/lib/analytics.ts b/landing/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/landing/lib/analytics.ts +++ b/landing/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/landing/package-lock.json b/landing/package-lock.json index 2358fa4..1f2d46b 100644 --- a/landing/package-lock.json +++ b/landing/package-lock.json @@ -20,7 +20,6 @@ "motion": "^12.40.0", "next": "16.2.0", "next-themes": "^0.4.4", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "server-only": "^0.0.1", @@ -1290,31 +1289,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2387,13 +2361,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@vercel/og": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.6.8.tgz", @@ -2641,20 +2608,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/css-background-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", @@ -2735,15 +2688,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -3519,66 +3463,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/posthog-js/node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -4138,19 +4022,6 @@ } } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/landing/package.json b/landing/package.json index ade6719..1d064ed 100644 --- a/landing/package.json +++ b/landing/package.json @@ -24,7 +24,6 @@ "motion": "^12.40.0", "next": "16.2.0", "next-themes": "^0.4.4", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "server-only": "^0.0.1", diff --git a/nextjs/.env.example b/nextjs/.env.example index 87151bb..8458374 100644 --- a/nextjs/.env.example +++ b/nextjs/.env.example @@ -6,9 +6,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your_insforge_anon_key_here NEXT_PUBLIC_APP_URL=http://localhost:3000 # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=nextjs diff --git a/nextjs/package-lock.json b/nextjs/package-lock.json index bb11890..32bb078 100644 --- a/nextjs/package-lock.json +++ b/nextjs/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@insforge/sdk": "^1.2.2", "next": "^15.5.7", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -1001,31 +1000,6 @@ "node": ">=12.4.0" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1122,7 +1096,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1138,13 +1112,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -2326,20 +2293,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2372,7 +2325,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2533,15 +2486,6 @@ "node": ">=0.10.0" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3286,12 +3230,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5046,54 +4984,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5126,12 +5016,6 @@ "node": ">=6" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6268,19 +6152,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/nextjs/package.json b/nextjs/package.json index fe0d68f..da3c074 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -11,7 +11,6 @@ "dependencies": { "@insforge/sdk": "^1.2.2", "next": "^15.5.7", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, diff --git a/nextjs/src/lib/analytics.ts b/nextjs/src/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/nextjs/src/lib/analytics.ts +++ b/nextjs/src/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/react/.env.example b/react/.env.example index a11cc8d..59d206d 100644 --- a/react/.env.example +++ b/react/.env.example @@ -8,9 +8,7 @@ VITE_INSFORGE_BASE_URL=https://your-app.region.insforge.app VITE_INSFORGE_ANON_KEY=your_insforge_anon_key_here # Platform-managed analytics (production only) -VITE_POSTHOG_PROJECT_TOKEN=phc_project_token -VITE_POSTHOG_HOST=https://us.i.posthog.com -VITE_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +VITE_GA_MEASUREMENT_ID= VITE_INSFORGE_APP_ID= VITE_INSFORGE_ENVIRONMENT_ID= VITE_INSFORGE_TEMPLATE_ID=react diff --git a/react/package-lock.json b/react/package-lock.json index 2a1c2e3..9593dc8 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.0", "dependencies": { "@insforge/sdk": "^1.2.2", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.13.2" @@ -1098,31 +1097,6 @@ "node": ">= 8" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1619,7 +1593,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1635,13 +1609,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -2330,20 +2297,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2376,7 +2329,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2417,15 +2370,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -2765,12 +2709,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3621,54 +3559,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3689,12 +3579,6 @@ "node": ">=6" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4424,19 +4308,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/react/package.json b/react/package.json index 25f04d9..7bc96a8 100644 --- a/react/package.json +++ b/react/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@insforge/sdk": "^1.2.2", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.13.2" diff --git a/react/src/lib/analytics.ts b/react/src/lib/analytics.ts index dab5de9..f6e4069 100644 --- a/react/src/lib/analytics.ts +++ b/react/src/lib/analytics.ts @@ -1,15 +1,19 @@ -import posthog from 'posthog-js' - export type AnalyticsValue = string | number | boolean | null | undefined export type AnalyticsProperties = Record const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer'] +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +} type RuntimeConfig = { - posthogHost?: string - posthogProjectToken?: string + gaMeasurementId?: string applicationId?: string environmentId?: string templateVersionId?: string @@ -19,98 +23,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false - return typeof value !== 'string' || !EMAIL_VALUE.test(value) - }), - ) as Record +let measurementId: string | undefined +let lastPagePath: string | undefined +let analyticsContext: Record = {} + +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue + if (typeof value !== 'string') { + sanitized[key] = value + continue + } + if (EMAIL_VALUE.test(value)) continue + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value) + sanitized[key] = `${url.origin}${url.pathname}` + continue + } catch { + continue + } + } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value + } + return sanitized } -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties } - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key] +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, + template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, + environment: 'production', } - for (const key of URL_KEYS) { - const value = sanitized[key] - if (typeof value !== 'string') continue - try { - const url = new URL(value) - url.search = '' - url.hash = '' - sanitized[key] = url.toString() - } catch { - delete sanitized[key] +} + +function pagePath(): string { + return window.location.pathname || '/' +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return + const path = pagePath() + if (path === lastPagePath) return + lastPagePath = path + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }) +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView) + } else { + window.setTimeout(trackPageView, 0) } } - return sanitized + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method] + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args) + notify() + return result + } as History[typeof method] + } + window.addEventListener('popstate', notify) } export function initializeAnalytics(): void { const runtime = window.__INSFORGE_RUNTIME_CONFIG__ - const token = runtime?.posthogProjectToken || import.meta.env.VITE_POSTHOG_PROJECT_TOKEN - if (!import.meta.env.PROD || !token || posthog.__loaded) return - - const configuredRate = Number(import.meta.env.VITE_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1') - const replaySampleRate = Number.isFinite(configuredRate) ? Math.min(1, Math.max(0, configuredRate)) : 0.1 - - posthog.init(token, { - api_host: runtime?.posthogHost || import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, - template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ) - }, - }) + const configuredId = runtime?.gaMeasurementId || import.meta.env.VITE_GA_MEASUREMENT_ID + if (!import.meta.env.PROD || !configuredId || measurementId) return + + measurementId = configuredId + window.dataLayer = window.dataLayer || [] + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)) + window.gtag('js', new Date()) + analyticsContext = safeProperties(commonProperties(runtime)) + window.gtag('set', analyticsContext) + window.gtag('config', measurementId, { send_page_view: false }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}` + script.dataset.insforgeAnalytics = 'ga4' + document.head.appendChild(script) + + installNavigationTracking() + trackPageView() } export function track(event: string, properties?: AnalyticsProperties): void { - if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) + if (!measurementId || !window.gtag) return + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }) } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (posthog.__loaded && userId && !EMAIL_VALUE.test(userId)) posthog.identify(userId) + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }) } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset() + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }) + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), } diff --git a/scripts/check-analytics.mjs b/scripts/check-analytics.mjs index 1781679..af0f23a 100644 --- a/scripts/check-analytics.mjs +++ b/scripts/check-analytics.mjs @@ -4,6 +4,7 @@ import { join } from 'node:path'; const root = new URL('..', import.meta.url).pathname; const ignored = new Set(['scripts', 'node_modules']); +const legacySdkPrefix = ['post', 'hog'].join(''); const templates = readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !ignored.has(entry.name)) .map((entry) => entry.name) @@ -20,23 +21,43 @@ for (const name of templates) { const document = isNext ? join(sourceRoot, 'app/layout.tsx') : join(directory, 'index.html'); const envExample = readFileSync(join(directory, '.env.example'), 'utf8'); - assert(pkg.dependencies?.['posthog-js'], `${name}: posthog-js must be a production dependency`); assert(existsSync(helper), `${name}: missing analytics helper`); assert(existsSync(bootstrap), `${name}: missing analytics bootstrap`); assert(existsSync(document), `${name}: missing root HTML document`); + assert(!Object.keys(pkg.dependencies ?? {}).some((dependency) => dependency.startsWith(legacySdkPrefix)), + `${name}: legacy analytics SDK must be removed`); const helperSource = readFileSync(helper, 'utf8'); const bootstrapSource = readFileSync(bootstrap, 'utf8'); const documentSource = readFileSync(document, 'utf8'); assert(helperSource.includes('__INSFORGE_RUNTIME_CONFIG__'), `${name}: runtime config is not preferred`); - assert(helperSource.includes('autocapture: false'), `${name}: unsafe autocapture must stay disabled`); - assert(helperSource.includes('maskAllInputs: true'), `${name}: replay inputs must be masked`); - assert(helperSource.includes('mask_personal_data_properties: true'), `${name}: PostHog person properties must be masked`); - assert(helperSource.includes('sampleRate: replaySampleRate'), `${name}: replay sampling must remain stable per session`); - assert(helperSource.includes('capture_performance: true'), `${name}: performance capture must be enabled`); - assert(helperSource.includes('sanitize_properties'), `${name}: URLs and PII must be sanitized`); - assert(helperSource.includes("process.env.NODE_ENV !== 'production'") || helperSource.includes('import.meta.env.PROD'), `${name}: analytics must be production-only`); + assert(helperSource.includes('gaMeasurementId'), `${name}: runtime GA4 measurement ID is missing`); + assert(helperSource.includes('googletagmanager.com/gtag/js'), `${name}: Google tag is not loaded`); + assert(helperSource.includes('send_page_view: false'), `${name}: automatic and manual page views may be duplicated`); + assert(helperSource.includes("'page_view'"), `${name}: page views are not captured`); + assert(helperSource.includes('window.location.pathname'), `${name}: page paths are not sanitized`); + assert(helperSource.includes('url.origin') && helperSource.includes('url.pathname'), + `${name}: URL-valued event properties are not stripped of query strings`); + assert(!helperSource.includes('window.location.href'), `${name}: raw URLs must not be reported`); + assert(helperSource.includes("['pushState', 'replaceState']"), `${name}: SPA navigation is not captured`); + assert(helperSource.includes("addEventListener('popstate'"), `${name}: browser back/forward is not captured`); + assert(helperSource.includes('requestAnimationFrame(trackPageView)'), + `${name}: SPA page views must wait for the destination title to render`); + assert(helperSource.includes('PII_KEY') && helperSource.includes('EMAIL_VALUE'), `${name}: PII guards are missing`); + assert(helperSource.includes("window.gtag('set', analyticsContext)"), `${name}: application context is not registered`); + assert(helperSource.includes("track('sign_up')"), `${name}: recommended sign_up event is missing`); + assert(helperSource.includes("track('login')"), `${name}: recommended login event is missing`); + assert(helperSource.includes("track('purchase'"), `${name}: recommended purchase event is missing`); + assert(helperSource.includes("form_submitted: 'form_submit'"), + `${name}: legacy form submissions must remain non-conversion events`); + assert(helperSource.includes("formSubmitted: (formId: string) => track('form_submit'"), + `${name}: ordinary forms must not be reported as leads`); + assert(helperSource.includes("leadSubmitted: (formId: string) => track('generate_lead'"), + `${name}: explicit generate_lead helper is missing`); + assert(helperSource.includes("process.env.NODE_ENV !== 'production'") || helperSource.includes('import.meta.env.PROD'), + `${name}: analytics must be production-only`); assert(bootstrapSource.includes('initializeAnalytics'), `${name}: analytics is not initialized`); + const runtimeConfigMatch = documentSource.match( /]*)src="\/\.well-known\/insforge-runtime-config\.js"([^>]*)>/, ); @@ -47,36 +68,29 @@ for (const name of templates) { const runtimeConfigIndex = runtimeConfigMatch.index; if (isNext) { assert(runtimeConfigIndex < documentSource.indexOf('= 0 && runtimeConfigIndex < moduleEntryIndex, `${name}: runtime config must load before the Vite module entry`); assert(/\bvite-ignore\b/.test(runtimeConfigAttributes), `${name}: runtime config script must opt out of Vite processing`); + assert(envExample.includes('VITE_GA_MEASUREMENT_ID'), `${name}: missing Vite GA4 env documentation`); } - assert(envExample.includes('POSTHOG_PROJECT_TOKEN'), `${name}: missing PostHog env documentation`); - assert(!pkg.dependencies?.['@vercel/analytics'], `${name}: do not mix Vercel and PostHog analytics`); + assert(!pkg.dependencies?.['@vercel/analytics'], `${name}: do not mix analytics providers`); } -const chatbotSources = [ +const pdfClientSources = [ 'components/sign-in-form.tsx', 'components/sign-up-form.tsx', - 'app/api/documents/upload/route.ts', - 'app/api/workspaces/route.ts', + 'components/flashcards-modal.tsx', + 'components/share-chat-button.tsx', ].map((path) => readFileSync(join(root, 'ai-pdf-chatbot', path), 'utf8')).join('\n'); -assert(!/posthog\.(?:capture|identify)[\s\S]{0,300}\b(?:email|name|file_name|workspace_name)\b/i.test(chatbotSources), - 'ai-pdf-chatbot: analytics payload contains a direct PII field'); - -for (const [template, path] of [ - ['chatbot', 'components/chat-shell.tsx'], - ['insight-flow-agent-chat', 'src/components/ChatPage.tsx'], - ['ai-pdf-chatbot', 'app/chat/page.tsx'], - ['ai-pdf-chatbot', 'app/chat/[chatId]/page.tsx'], -]) { - assert(readFileSync(join(root, template, path), 'utf8').includes('data-private'), - `${template}: conversation content must be excluded from session replay text capture`); +for (const call of pdfClientSources.matchAll(/analytics\.track\([^;]*\)/gs)) { + assert(!/\b(?:email|name|file_name|workspace_name)\s*:/i.test(call[0]), + 'ai-pdf-chatbot: analytics payload contains a direct PII field'); } -console.log(`Analytics contract validated for ${templates.length} templates.`); +console.log(`GA4 analytics contract validated for ${templates.length} templates.`); diff --git a/todo/.env.example b/todo/.env.example index 4292fa2..57c81cd 100644 --- a/todo/.env.example +++ b/todo/.env.example @@ -6,9 +6,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your_insforge_anon_key_here NEXT_PUBLIC_APP_URL=http://localhost:3000 # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=todo diff --git a/todo/package-lock.json b/todo/package-lock.json index bb11890..32bb078 100644 --- a/todo/package-lock.json +++ b/todo/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@insforge/sdk": "^1.2.2", "next": "^15.5.7", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -1001,31 +1000,6 @@ "node": ">=12.4.0" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1122,7 +1096,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1138,13 +1112,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -2326,20 +2293,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2372,7 +2325,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2533,15 +2486,6 @@ "node": ">=0.10.0" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3286,12 +3230,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5046,54 +4984,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5126,12 +5016,6 @@ "node": ">=6" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6268,19 +6152,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/todo/package.json b/todo/package.json index fe0d68f..da3c074 100644 --- a/todo/package.json +++ b/todo/package.json @@ -11,7 +11,6 @@ "dependencies": { "@insforge/sdk": "^1.2.2", "next": "^15.5.7", - "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, diff --git a/todo/src/lib/analytics.ts b/todo/src/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/todo/src/lib/analytics.ts +++ b/todo/src/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/web-research-agent/.env.example b/web-research-agent/.env.example index 115ffed..c4e69e2 100644 --- a/web-research-agent/.env.example +++ b/web-research-agent/.env.example @@ -4,9 +4,7 @@ LEXMOUNT_BROWSER_API_URL= LEXMOUNT_BROWSER_API_KEY= # Platform-managed analytics (production only) -VITE_POSTHOG_PROJECT_TOKEN=phc_project_token -VITE_POSTHOG_HOST=https://us.i.posthog.com -VITE_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +VITE_GA_MEASUREMENT_ID= VITE_INSFORGE_APP_ID= VITE_INSFORGE_ENVIRONMENT_ID= VITE_INSFORGE_TEMPLATE_ID=web-research-agent diff --git a/web-research-agent/package-lock.json b/web-research-agent/package-lock.json index 620dcde..ccf2a2d 100644 --- a/web-research-agent/package-lock.json +++ b/web-research-agent/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", - "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -893,31 +892,6 @@ "node": ">= 8" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1397,14 +1371,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1421,13 +1395,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1673,20 +1640,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1704,7 +1657,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -1738,15 +1691,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.413", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", @@ -1878,12 +1822,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2428,60 +2366,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3047,19 +2931,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/web-research-agent/package.json b/web-research-agent/package.json index 5f29b9b..39f923e 100644 --- a/web-research-agent/package.json +++ b/web-research-agent/package.json @@ -11,7 +11,6 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", - "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/web-research-agent/src/lib/analytics.ts b/web-research-agent/src/lib/analytics.ts index dab5de9..f6e4069 100644 --- a/web-research-agent/src/lib/analytics.ts +++ b/web-research-agent/src/lib/analytics.ts @@ -1,15 +1,19 @@ -import posthog from 'posthog-js' - export type AnalyticsValue = string | number | boolean | null | undefined export type AnalyticsProperties = Record const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer'] +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +} type RuntimeConfig = { - posthogHost?: string - posthogProjectToken?: string + gaMeasurementId?: string applicationId?: string environmentId?: string templateVersionId?: string @@ -19,98 +23,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false - return typeof value !== 'string' || !EMAIL_VALUE.test(value) - }), - ) as Record +let measurementId: string | undefined +let lastPagePath: string | undefined +let analyticsContext: Record = {} + +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue + if (typeof value !== 'string') { + sanitized[key] = value + continue + } + if (EMAIL_VALUE.test(value)) continue + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value) + sanitized[key] = `${url.origin}${url.pathname}` + continue + } catch { + continue + } + } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value + } + return sanitized } -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties } - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key] +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, + template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, + environment: 'production', } - for (const key of URL_KEYS) { - const value = sanitized[key] - if (typeof value !== 'string') continue - try { - const url = new URL(value) - url.search = '' - url.hash = '' - sanitized[key] = url.toString() - } catch { - delete sanitized[key] +} + +function pagePath(): string { + return window.location.pathname || '/' +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return + const path = pagePath() + if (path === lastPagePath) return + lastPagePath = path + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }) +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView) + } else { + window.setTimeout(trackPageView, 0) } } - return sanitized + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method] + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args) + notify() + return result + } as History[typeof method] + } + window.addEventListener('popstate', notify) } export function initializeAnalytics(): void { const runtime = window.__INSFORGE_RUNTIME_CONFIG__ - const token = runtime?.posthogProjectToken || import.meta.env.VITE_POSTHOG_PROJECT_TOKEN - if (!import.meta.env.PROD || !token || posthog.__loaded) return - - const configuredRate = Number(import.meta.env.VITE_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1') - const replaySampleRate = Number.isFinite(configuredRate) ? Math.min(1, Math.max(0, configuredRate)) : 0.1 - - posthog.init(token, { - api_host: runtime?.posthogHost || import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, - template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ) - }, - }) + const configuredId = runtime?.gaMeasurementId || import.meta.env.VITE_GA_MEASUREMENT_ID + if (!import.meta.env.PROD || !configuredId || measurementId) return + + measurementId = configuredId + window.dataLayer = window.dataLayer || [] + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)) + window.gtag('js', new Date()) + analyticsContext = safeProperties(commonProperties(runtime)) + window.gtag('set', analyticsContext) + window.gtag('config', measurementId, { send_page_view: false }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}` + script.dataset.insforgeAnalytics = 'ga4' + document.head.appendChild(script) + + installNavigationTracking() + trackPageView() } export function track(event: string, properties?: AnalyticsProperties): void { - if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) + if (!measurementId || !window.gtag) return + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }) } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (posthog.__loaded && userId && !EMAIL_VALUE.test(userId)) posthog.identify(userId) + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }) } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset() + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }) + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), } diff --git a/website-change-monitor/.env.example b/website-change-monitor/.env.example index 6a77987..bd93513 100644 --- a/website-change-monitor/.env.example +++ b/website-change-monitor/.env.example @@ -4,9 +4,7 @@ LEXMOUNT_BROWSER_API_URL= LEXMOUNT_BROWSER_API_KEY= # Platform-managed analytics (production only) -VITE_POSTHOG_PROJECT_TOKEN=phc_project_token -VITE_POSTHOG_HOST=https://us.i.posthog.com -VITE_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +VITE_GA_MEASUREMENT_ID= VITE_INSFORGE_APP_ID= VITE_INSFORGE_ENVIRONMENT_ID= VITE_INSFORGE_TEMPLATE_ID=website-change-monitor diff --git a/website-change-monitor/package-lock.json b/website-change-monitor/package-lock.json index 2346ea8..0d57c74 100644 --- a/website-change-monitor/package-lock.json +++ b/website-change-monitor/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", - "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -893,31 +892,6 @@ "node": ">= 8" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1397,14 +1371,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1421,13 +1395,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1673,20 +1640,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1704,7 +1657,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -1738,15 +1691,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.413", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", @@ -1878,12 +1822,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2428,60 +2366,6 @@ "dev": true, "license": "MIT" }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3047,19 +2931,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/website-change-monitor/package.json b/website-change-monitor/package.json index e73cf5f..77727ba 100644 --- a/website-change-monitor/package.json +++ b/website-change-monitor/package.json @@ -11,7 +11,6 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", - "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/website-change-monitor/src/lib/analytics.ts b/website-change-monitor/src/lib/analytics.ts index dab5de9..f6e4069 100644 --- a/website-change-monitor/src/lib/analytics.ts +++ b/website-change-monitor/src/lib/analytics.ts @@ -1,15 +1,19 @@ -import posthog from 'posthog-js' - export type AnalyticsValue = string | number | boolean | null | undefined export type AnalyticsProperties = Record const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer'] +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +} type RuntimeConfig = { - posthogHost?: string - posthogProjectToken?: string + gaMeasurementId?: string applicationId?: string environmentId?: string templateVersionId?: string @@ -19,98 +23,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + dataLayer?: unknown[] + gtag?: (...args: unknown[]) => void + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false - return typeof value !== 'string' || !EMAIL_VALUE.test(value) - }), - ) as Record +let measurementId: string | undefined +let lastPagePath: string | undefined +let analyticsContext: Record = {} + +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue + if (typeof value !== 'string') { + sanitized[key] = value + continue + } + if (EMAIL_VALUE.test(value)) continue + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value) + sanitized[key] = `${url.origin}${url.pathname}` + continue + } catch { + continue + } + } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value + } + return sanitized } -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties } - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key] +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, + template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, + environment: 'production', } - for (const key of URL_KEYS) { - const value = sanitized[key] - if (typeof value !== 'string') continue - try { - const url = new URL(value) - url.search = '' - url.hash = '' - sanitized[key] = url.toString() - } catch { - delete sanitized[key] +} + +function pagePath(): string { + return window.location.pathname || '/' +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return + const path = pagePath() + if (path === lastPagePath) return + lastPagePath = path + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }) +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView) + } else { + window.setTimeout(trackPageView, 0) } } - return sanitized + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method] + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args) + notify() + return result + } as History[typeof method] + } + window.addEventListener('popstate', notify) } export function initializeAnalytics(): void { const runtime = window.__INSFORGE_RUNTIME_CONFIG__ - const token = runtime?.posthogProjectToken || import.meta.env.VITE_POSTHOG_PROJECT_TOKEN - if (!import.meta.env.PROD || !token || posthog.__loaded) return - - const configuredRate = Number(import.meta.env.VITE_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1') - const replaySampleRate = Number.isFinite(configuredRate) ? Math.min(1, Math.max(0, configuredRate)) : 0.1 - - posthog.init(token, { - api_host: runtime?.posthogHost || import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || import.meta.env.VITE_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || import.meta.env.VITE_INSFORGE_ENVIRONMENT_ID, - template_id: import.meta.env.VITE_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || import.meta.env.VITE_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || import.meta.env.VITE_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ) - }, - }) + const configuredId = runtime?.gaMeasurementId || import.meta.env.VITE_GA_MEASUREMENT_ID + if (!import.meta.env.PROD || !configuredId || measurementId) return + + measurementId = configuredId + window.dataLayer = window.dataLayer || [] + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)) + window.gtag('js', new Date()) + analyticsContext = safeProperties(commonProperties(runtime)) + window.gtag('set', analyticsContext) + window.gtag('config', measurementId, { send_page_view: false }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}` + script.dataset.insforgeAnalytics = 'ga4' + document.head.appendChild(script) + + installNavigationTracking() + trackPageView() } export function track(event: string, properties?: AnalyticsProperties): void { - if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) + if (!measurementId || !window.gtag) return + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }) } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (posthog.__loaded && userId && !EMAIL_VALUE.test(userId)) posthog.identify(userId) + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }) } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset() + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }) + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), } diff --git a/workspace/.env.example b/workspace/.env.example index 8f03bbd..28fb80b 100644 --- a/workspace/.env.example +++ b/workspace/.env.example @@ -3,9 +3,7 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key NEXT_PUBLIC_APP_URL=http://localhost:3000 # Platform-managed analytics (production only) -NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=phc_project_token -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com -NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE=0.1 +NEXT_PUBLIC_GA_MEASUREMENT_ID= NEXT_PUBLIC_INSFORGE_APP_ID= NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=workspace diff --git a/workspace/lib/analytics.ts b/workspace/lib/analytics.ts index 2fc4b40..8ae7114 100644 --- a/workspace/lib/analytics.ts +++ b/workspace/lib/analytics.ts @@ -1,17 +1,21 @@ 'use client'; -import posthog from 'posthog-js'; - export type AnalyticsValue = string | number | boolean | null | undefined; export type AnalyticsProperties = Record; const PII_KEY = /(^|_)(email|e_mail|name|first_name|last_name|phone|mobile|address|password|token|secret|authorization|cookie|message|prompt|content|query)(_|$)/i; const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const URL_KEYS = ['$current_url', '$referrer', '$initial_current_url', '$initial_referrer']; +const EVENT_ALIASES: Record = { + cta_clicked: 'cta_click', + form_started: 'form_start', + form_submitted: 'form_submit', + sign_up_completed: 'sign_up', + login_completed: 'login', + purchase_completed: 'purchase', +}; type RuntimeConfig = { - posthogHost?: string; - posthogProjectToken?: string; + gaMeasurementId?: string; applicationId?: string; environmentId?: string; templateVersionId?: string; @@ -21,102 +25,141 @@ type RuntimeConfig = { declare global { interface Window { __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + __INSFORGE_GA4_NAVIGATION_TRACKING__?: boolean; } } -function safeProperties(properties: AnalyticsProperties = {}): Record { - return Object.fromEntries( - Object.entries(properties).filter(([key, value]) => { - if (value === undefined || PII_KEY.test(key)) return false; - return typeof value !== 'string' || !EMAIL_VALUE.test(value); - }), - ) as Record; -} +let measurementId: string | undefined; +let lastPagePath: string | undefined; +let analyticsContext: Record = {}; -function sanitizePostHogProperties(properties: Record): Record { - const sanitized: Record = { ...properties }; - for (const key of Object.keys(sanitized)) { - if (!key.startsWith('$') && PII_KEY.test(key)) delete sanitized[key]; - } - for (const key of URL_KEYS) { - const value = sanitized[key]; - if (typeof value !== 'string') continue; - try { - const url = new URL(value); - url.search = ''; - url.hash = ''; - sanitized[key] = url.toString(); - } catch { - delete sanitized[key]; +function safeProperties(properties: AnalyticsProperties = {}): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined || value === null || PII_KEY.test(key)) continue; + if (typeof value !== 'string') { + sanitized[key] = value; + continue; + } + if (EMAIL_VALUE.test(value)) continue; + if (/^https?:\/\//i.test(value)) { + try { + const url = new URL(value); + sanitized[key] = `${url.origin}${url.pathname}`; + continue; + } catch { + continue; + } } + sanitized[key] = value.startsWith('/') ? value.split(/[?#]/, 1)[0] || '/' : value; } return sanitized; } +function commonProperties(runtime: RuntimeConfig | undefined): AnalyticsProperties { + return { + application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, + environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, + template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, + template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, + release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, + environment: 'production', + }; +} + +function pagePath(): string { + return window.location.pathname || '/'; +} + +function trackPageView(): void { + if (!measurementId || !window.gtag) return; + const path = pagePath(); + if (path === lastPagePath) return; + lastPagePath = path; + window.gtag('event', 'page_view', { + ...analyticsContext, + page_location: `${window.location.origin}${path}`, + page_path: path, + page_title: document.title, + }); +} + +function installNavigationTracking(): void { + if (window.__INSFORGE_GA4_NAVIGATION_TRACKING__) return; + window.__INSFORGE_GA4_NAVIGATION_TRACKING__ = true; + const notify = () => { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(trackPageView); + } else { + window.setTimeout(trackPageView, 0); + } + }; + for (const method of ['pushState', 'replaceState'] as const) { + const original = history[method]; + history[method] = function (this: History, ...args: Parameters) { + const result = original.apply(this, args); + notify(); + return result; + } as History[typeof method]; + } + window.addEventListener('popstate', notify); +} + export function initializeAnalytics(): void { const runtime = typeof window === 'undefined' ? undefined : window.__INSFORGE_RUNTIME_CONFIG__; - const token = runtime?.posthogProjectToken || process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN; - if (process.env.NODE_ENV !== 'production' || !token || posthog.__loaded) return; - - const configuredRate = Number(process.env.NEXT_PUBLIC_POSTHOG_REPLAY_SAMPLE_RATE ?? '0.1'); - const replaySampleRate = Number.isFinite(configuredRate) - ? Math.min(1, Math.max(0, configuredRate)) - : 0.1; - - posthog.init(token, { - api_host: runtime?.posthogHost || process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - autocapture: false, - capture_pageview: 'history_change', - capture_pageleave: true, - capture_performance: true, - person_profiles: 'identified_only', - mask_personal_data_properties: true, - sanitize_properties: sanitizePostHogProperties, - session_recording: { - sampleRate: replaySampleRate, - maskAllInputs: true, - maskTextSelector: '[data-private]', - blockSelector: '[data-private]', - }, - loaded(client) { - client.register( - safeProperties({ - application_id: runtime?.applicationId || process.env.NEXT_PUBLIC_INSFORGE_APP_ID, - environment_id: runtime?.environmentId || process.env.NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID, - template_id: process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_ID, - template_version_id: runtime?.templateVersionId || process.env.NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID, - release_id: runtime?.releaseId || process.env.NEXT_PUBLIC_INSFORGE_RELEASE_ID, - environment: 'production', - }), - ); - }, - }); + const configuredId = runtime?.gaMeasurementId || process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; + if (process.env.NODE_ENV !== 'production' || !configuredId || measurementId) return; + + measurementId = configuredId; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || ((...args: unknown[]) => window.dataLayer?.push(args)); + window.gtag('js', new Date()); + analyticsContext = safeProperties(commonProperties(runtime)); + window.gtag('set', analyticsContext); + window.gtag('config', measurementId, { send_page_view: false }); + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(configuredId)}`; + script.dataset.insforgeAnalytics = 'ga4'; + document.head.appendChild(script); + + installNavigationTracking(); + trackPageView(); } export function track(event: string, properties?: AnalyticsProperties): void { - if (!posthog.__loaded) return; - posthog.capture(event, safeProperties(properties)); + if (!measurementId || !window.gtag) return; + window.gtag('event', EVENT_ALIASES[event] || event, { + ...safeProperties(properties), + ...analyticsContext, + }); } /** Identify only with an opaque application user ID; never pass email or profile properties. */ export function identify(userId: string): void { - if (!posthog.__loaded || !userId || EMAIL_VALUE.test(userId)) return; - posthog.identify(userId); + if (!measurementId || !window.gtag || !userId || EMAIL_VALUE.test(userId)) return; + window.gtag('config', measurementId, { user_id: userId, send_page_view: false }); } export function resetAnalytics(): void { - if (posthog.__loaded) posthog.reset(); + if (measurementId && window.gtag) { + window.gtag('config', measurementId, { user_id: null, send_page_view: false }); + } } export const analytics = { track, identify, reset: resetAnalytics, - ctaClicked: (ctaId: string, location: string) => track('cta_clicked', { cta_id: ctaId, location }), - formStarted: (formId: string) => track('form_started', { form_id: formId }), - formSubmitted: (formId: string) => track('form_submitted', { form_id: formId }), - signUpCompleted: () => track('sign_up_completed'), - loginCompleted: () => track('login_completed'), + ctaClicked: (ctaId: string, location: string) => track('cta_click', { cta_id: ctaId, location }), + formStarted: (formId: string) => track('form_start', { form_id: formId }), + formSubmitted: (formId: string) => track('form_submit', { form_id: formId }), + leadSubmitted: (formId: string) => track('generate_lead', { form_id: formId }), + signUpCompleted: () => track('sign_up'), + loginCompleted: () => track('login'), purchaseCompleted: (transactionId: string, value?: number, currency?: string) => - track('purchase_completed', { transaction_id: transactionId, value, currency }), + track('purchase', { transaction_id: transactionId, value, currency }), }; diff --git a/workspace/package-lock.json b/workspace/package-lock.json index f3e119c..679f8a6 100644 --- a/workspace/package-lock.json +++ b/workspace/package-lock.json @@ -25,7 +25,6 @@ "nanoid": "^5.0.9", "next": "16.2.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7", @@ -1052,31 +1051,6 @@ "node": ">= 10" } }, - "node_modules/@posthog/browser-common": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.7.1.tgz", - "integrity": "sha512-JO/5dIVx3NTrbg0W2VciDDmmizsvwaYYHE5cnJE1Gra+UqBnEuVGH9DTE/kBFgVMp+08z5uj/aAprsYD2WG/QQ==", - "license": "MIT", - "dependencies": { - "@posthog/core": "^1.50.0", - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/core": { - "version": "1.50.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.50.2.tgz", - "integrity": "sha512-El7y/ipZ45OFBRTN/+ixat9Nw21bW+pKKnX0G+rG4rWxBtDA47Ub/ZUnHvZNhAufNInQr4AYCUeWQsuJsTcoKg==", - "license": "MIT", - "dependencies": { - "@posthog/types": "^1.407.1" - } - }, - "node_modules/@posthog/types": { - "version": "1.408.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.408.0.tgz", - "integrity": "sha512-xMY8QrRKsTYu4A4Of7XF/xEt0HcnZOWwnICm3do7tGKA0lTlnnr4YW0AHQN1Uv2P3BDqf5GQoqh91MKE+dgdcA==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2051,6 +2025,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -2421,13 +2461,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2577,20 +2610,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2665,15 +2684,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/emoji-mart": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", @@ -2761,12 +2771,6 @@ "node": ">=6.0.0" } }, - "node_modules/fflate": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", - "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", - "license": "MIT" - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4425,54 +4429,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/posthog-js": { - "version": "1.425.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.425.1.tgz", - "integrity": "sha512-GfdAUf402Prjt3ofkj4dh4k/m1SkYvgCeASyWj1xxcIVT7QnB9gGsslDwAZBZ6Wrtwxkq91gFbpSo/v633Q+1w==", - "license": "(Apache-2.0 AND MIT)", - "dependencies": { - "@posthog/browser-common": "^0.7.1", - "@posthog/core": "^1.50.2", - "@posthog/types": "^1.408.0", - "core-js": "^3.49.0", - "dompurify": "^3.4.13", - "fflate": "^0.4.8", - "preact": "^10.29.3", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^6.2.1", - "web-vitals-soft-navs": "npm:web-vitals@6.2.1" - }, - "peerDependencies": { - "@types/react": ">=16.8.0", - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/preact": { - "version": "10.29.8", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", - "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - }, - "peerDependencies": { - "preact-render-to-string": ">=5" - }, - "peerDependenciesMeta": { - "preact-render-to-string": { - "optional": true - } - } - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -4665,12 +4621,6 @@ "prosemirror-transform": "^1.1.0" } }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -5486,19 +5436,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/web-vitals": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, - "node_modules/web-vitals-soft-navs": { - "name": "web-vitals", - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", - "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/workspace/package.json b/workspace/package.json index 3e2d8ad..dc0cb79 100644 --- a/workspace/package.json +++ b/workspace/package.json @@ -27,7 +27,6 @@ "nanoid": "^5.0.9", "next": "16.2.0", "next-themes": "^0.4.6", - "posthog-js": "^1.425.1", "react": "19.2.0", "react-dom": "19.2.0", "sonner": "^2.0.7",