diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7bdd5d6..80d4400 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -31,6 +31,16 @@ jobs: working-directory: scripts run: npm test + - name: Validate analytics contract + run: node scripts/check-analytics.mjs + + # We deliberately do NOT run `npm install` on each template in CI: + # contributors install locally before opening a PR, package-lock.json + # makes the install reproducible, and the slow per-template install + # would dominate CI time as the registry grows. The static checks below + # (JSON parse, file existence, secret patterns, SQL parse, edge-function + # tsc) cover what install would catch except the rare "package was + # yanked from npm" case — we'll catch that via Dependabot/Snyk later. edge-function-tsc: name: tsc --noEmit on edge functions runs-on: ubuntu-latest diff --git a/ANALYTICS.md b/ANALYTICS.md new file mode 100644 index 0000000..0daefb0 --- /dev/null +++ b/ANALYTICS.md @@ -0,0 +1,49 @@ +# 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. + +## Runtime configuration + +The platform serves `/.well-known/insforge-runtime-config.js`; every template loads it synchronously +before the application bundle so it can inject `window.__INSFORGE_RUNTIME_CONFIG__`: + +```ts +{ + posthogHost: 'https://us.i.posthog.com', + posthogProjectToken: 'phc_...', + applicationId: '...', + environmentId: '...', + templateVersionId: '...', + releaseId: '...' +} +``` + +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. + +## Event rules + +Import `analytics` from the template's `lib/analytics` module. Use its semantic helpers when they +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. + +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. diff --git a/README.md b/README.md index d5c839e..fae1ccd 100644 --- a/README.md +++ b/README.md @@ -69,6 +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) - 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 fee62fb..39b5a61 100644 --- a/admin-dashboard/.env.example +++ b/admin-dashboard/.env.example @@ -1,2 +1,12 @@ 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_INSFORGE_APP_ID= +VITE_INSFORGE_ENVIRONMENT_ID= +VITE_INSFORGE_TEMPLATE_ID=admin-dashboard +VITE_INSFORGE_TEMPLATE_VERSION_ID= +VITE_INSFORGE_RELEASE_ID= diff --git a/admin-dashboard/index.html b/admin-dashboard/index.html index 60d2647..984de60 100644 --- a/admin-dashboard/index.html +++ b/admin-dashboard/index.html @@ -8,7 +8,7 @@
- + diff --git a/admin-dashboard/package-lock.json b/admin-dashboard/package-lock.json index 829eed6..1b7a993 100644 --- a/admin-dashboard/package-lock.json +++ b/admin-dashboard/package-lock.json @@ -37,6 +37,7 @@ "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", @@ -990,6 +991,31 @@ "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", @@ -3207,6 +3233,13 @@ "@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", @@ -3501,6 +3534,20 @@ "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", @@ -3711,6 +3758,15 @@ "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", @@ -3853,6 +3909,12 @@ "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", @@ -4456,6 +4518,54 @@ "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", @@ -4489,6 +4599,12 @@ "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", @@ -5416,6 +5532,19 @@ "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 00cd1f8..edb34d1 100644 --- a/admin-dashboard/package.json +++ b/admin-dashboard/package.json @@ -43,6 +43,7 @@ "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 new file mode 100644 index 0000000..dab5de9 --- /dev/null +++ b/admin-dashboard/src/lib/analytics.ts @@ -0,0 +1,116 @@ +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'] + +type RuntimeConfig = { + posthogHost?: string + posthogProjectToken?: string + applicationId?: string + environmentId?: string + templateVersionId?: string + releaseId?: string +} + +declare global { + interface Window { + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +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 +} + +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] + } + } + return sanitized +} + +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', + }), + ) + }, + }) +} + +export function track(event: string, properties?: AnalyticsProperties): void { + if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) +} + +/** 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) +} + +export function resetAnalytics(): void { + if (posthog.__loaded) posthog.reset() +} + +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'), + purchaseCompleted: (transactionId: string, value?: number, currency?: string) => + track('purchase_completed', { transaction_id: transactionId, value, currency }), +} diff --git a/admin-dashboard/src/main.tsx b/admin-dashboard/src/main.tsx index 949a11e..38333db 100644 --- a/admin-dashboard/src/main.tsx +++ b/admin-dashboard/src/main.tsx @@ -5,6 +5,9 @@ import { RouterProvider, createRouter } from '@tanstack/react-router' import { Toaster } from 'sonner' import { routeTree } from './routeTree.gen' import './styles/globals.css' +import { initializeAnalytics } from './lib/analytics' + +initializeAnalytics() const queryClient = new QueryClient({ defaultOptions: { diff --git a/ai-pdf-chatbot/.env.example b/ai-pdf-chatbot/.env.example index a1d174f..0f4d63d 100644 --- a/ai-pdf-chatbot/.env.example +++ b/ai-pdf-chatbot/.env.example @@ -24,10 +24,12 @@ INSFORGE_JWT_SECRET=replace-with-output-of-cli-secrets-get-JWT_SECRET # this key. OPENAI_API_KEY= -# ─── PostHog Analytics (optional) ────────────────────────────────────── -# Visitor / event analytics. Leave blank to disable; the app still -# working, no events are sent. Get a project token from -# https://us.posthog.com/project//settings/project (or your -# self-hosted instance). +# ─── 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_INSFORGE_APP_ID= +NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= +NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=ai-pdf-chatbot +NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID= +NEXT_PUBLIC_INSFORGE_RELEASE_ID= diff --git a/ai-pdf-chatbot/app/api/documents/upload/route.ts b/ai-pdf-chatbot/app/api/documents/upload/route.ts index ddbabab..ceae58f 100644 --- a/ai-pdf-chatbot/app/api/documents/upload/route.ts +++ b/ai-pdf-chatbot/app/api/documents/upload/route.ts @@ -93,7 +93,6 @@ export async function POST(req: Request) { event: 'document_uploaded', properties: { document_id: doc.id, - file_name: file.name, file_size_bytes: file.size, workspace_id: workspaceId, ingest_status: ingestResult.status, 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 189914f..0b493cb 100644 --- a/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/audio/route.ts @@ -103,7 +103,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st posthog.capture({ distinctId: auth.viewer.id, event: 'audio_overview_generated', - properties: { workspace_id: id, workspace_name: ws.name, document_count: docs.length, script_turn_count: script.length }, + 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. 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 2b18eba..c83a6a6 100644 --- a/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/[id]/mindmap/route.ts @@ -95,7 +95,7 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st posthog.capture({ distinctId: auth.viewer.id ?? 'anonymous', event: 'mindmap_generated', - properties: { workspace_id: id, workspace_name: ws.name, document_count: docs.length }, + 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. diff --git a/ai-pdf-chatbot/app/api/workspaces/route.ts b/ai-pdf-chatbot/app/api/workspaces/route.ts index c7cdea0..d2cb0fc 100644 --- a/ai-pdf-chatbot/app/api/workspaces/route.ts +++ b/ai-pdf-chatbot/app/api/workspaces/route.ts @@ -55,7 +55,7 @@ export async function POST(req: Request) { posthog.capture({ distinctId: auth.viewer.id, event: 'workspace_created', - properties: { workspace_id: ws.id, workspace_name: name }, + 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. diff --git a/ai-pdf-chatbot/app/chat/[chatId]/page.tsx b/ai-pdf-chatbot/app/chat/[chatId]/page.tsx index b9b644d..fa1f11c 100644 --- a/ai-pdf-chatbot/app/chat/[chatId]/page.tsx +++ b/ai-pdf-chatbot/app/chat/[chatId]/page.tsx @@ -76,7 +76,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ chatId: s onShareTokenChange={setShareToken} /> -
+
{messages .filter((m) => m.role !== 'system') diff --git a/ai-pdf-chatbot/app/chat/page.tsx b/ai-pdf-chatbot/app/chat/page.tsx index 998187c..0426aa1 100644 --- a/ai-pdf-chatbot/app/chat/page.tsx +++ b/ai-pdf-chatbot/app/chat/page.tsx @@ -88,7 +88,7 @@ function ChatHomePageInner() { return ( <> -
+
{pendingInput ? (
diff --git a/ai-pdf-chatbot/app/layout.tsx b/ai-pdf-chatbot/app/layout.tsx index 35be808..6c7ad63 100644 --- a/ai-pdf-chatbot/app/layout.tsx +++ b/ai-pdf-chatbot/app/layout.tsx @@ -61,6 +61,9 @@ export default function RootLayout({ }) { return ( + + + diff --git a/insight-flow-agent-chat/package-lock.json b/insight-flow-agent-chat/package-lock.json index cf919ce..4a085c9 100644 --- a/insight-flow-agent-chat/package-lock.json +++ b/insight-flow-agent-chat/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -895,6 +896,31 @@ "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", @@ -1462,6 +1488,13 @@ "@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", @@ -1922,6 +1955,20 @@ "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", @@ -2007,6 +2054,15 @@ "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", @@ -2193,6 +2249,12 @@ "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", @@ -3758,6 +3820,54 @@ "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", @@ -3768,6 +3878,12 @@ "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", @@ -4754,6 +4870,19 @@ "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 f6d44d9..f698333 100644 --- a/insight-flow-agent-chat/package.json +++ b/insight-flow-agent-chat/package.json @@ -13,6 +13,7 @@ "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/components/ChatPage.tsx b/insight-flow-agent-chat/src/components/ChatPage.tsx index dd5ed66..b3bfd70 100644 --- a/insight-flow-agent-chat/src/components/ChatPage.tsx +++ b/insight-flow-agent-chat/src/components/ChatPage.tsx @@ -184,7 +184,7 @@ export function ChatPage({ email, navigate, onSignOut }: ChatPageProps) { -
+
{configLoading ? (
) : !config?.configured ? ( diff --git a/insight-flow-agent-chat/src/lib/analytics.ts b/insight-flow-agent-chat/src/lib/analytics.ts new file mode 100644 index 0000000..051d123 --- /dev/null +++ b/insight-flow-agent-chat/src/lib/analytics.ts @@ -0,0 +1,124 @@ +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']; + +type RuntimeConfig = { + posthogHost?: string; + posthogProjectToken?: string; + applicationId?: string; + environmentId?: string; + templateVersionId?: string; + releaseId?: string; +}; + +declare global { + interface Window { + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig; + } +} + +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; +} + +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]; + } + } + return sanitized; +} + +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', + }), + ); + }, + }); +} + +export function track(event: string, properties?: AnalyticsProperties): void { + if (posthog.__loaded) posthog.capture(event, safeProperties(properties)); +} + +/** 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); +} + +export function resetAnalytics(): void { + if (posthog.__loaded) posthog.reset(); +} + +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'), + purchaseCompleted: (transactionId: string, value?: number, currency?: string) => + track('purchase_completed', { transaction_id: transactionId, value, currency }), +}; diff --git a/insight-flow-agent-chat/src/main.tsx b/insight-flow-agent-chat/src/main.tsx index 9aa52ff..aaaa567 100644 --- a/insight-flow-agent-chat/src/main.tsx +++ b/insight-flow-agent-chat/src/main.tsx @@ -2,6 +2,9 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; +import { initializeAnalytics } from './lib/analytics'; + +initializeAnalytics(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/landing/.env.example b/landing/.env.example index 932d28b..8a84a07 100644 --- a/landing/.env.example +++ b/landing/.env.example @@ -5,3 +5,13 @@ NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key # App 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_INSFORGE_APP_ID= +NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= +NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=landing +NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID= +NEXT_PUBLIC_INSFORGE_RELEASE_ID= diff --git a/landing/app/layout.tsx b/landing/app/layout.tsx index 413facc..ab80d84 100644 --- a/landing/app/layout.tsx +++ b/landing/app/layout.tsx @@ -27,6 +27,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + + + diff --git a/react/package-lock.json b/react/package-lock.json index 9593dc8..2a1c2e3 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -9,6 +9,7 @@ "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" @@ -1097,6 +1098,31 @@ "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", @@ -1593,7 +1619,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1609,6 +1635,13 @@ "@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", @@ -2297,6 +2330,20 @@ "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", @@ -2329,7 +2376,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2370,6 +2417,15 @@ "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", @@ -2709,6 +2765,12 @@ "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", @@ -3559,6 +3621,54 @@ "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", @@ -3579,6 +3689,12 @@ "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", @@ -4308,6 +4424,19 @@ "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 7bc96a8..25f04d9 100644 --- a/react/package.json +++ b/react/package.json @@ -12,6 +12,7 @@ }, "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 new file mode 100644 index 0000000..dab5de9 --- /dev/null +++ b/react/src/lib/analytics.ts @@ -0,0 +1,116 @@ +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'] + +type RuntimeConfig = { + posthogHost?: string + posthogProjectToken?: string + applicationId?: string + environmentId?: string + templateVersionId?: string + releaseId?: string +} + +declare global { + interface Window { + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +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 +} + +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] + } + } + return sanitized +} + +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', + }), + ) + }, + }) +} + +export function track(event: string, properties?: AnalyticsProperties): void { + if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) +} + +/** 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) +} + +export function resetAnalytics(): void { + if (posthog.__loaded) posthog.reset() +} + +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'), + purchaseCompleted: (transactionId: string, value?: number, currency?: string) => + track('purchase_completed', { transaction_id: transactionId, value, currency }), +} diff --git a/react/src/main.tsx b/react/src/main.tsx index e57fab6..11b6c99 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -5,6 +5,9 @@ import './index.css'; import App from './App.tsx'; import { AuthProvider } from './lib/auth-context.tsx'; import { ThemeProvider } from './lib/theme.tsx'; +import { initializeAnalytics } from './lib/analytics'; + +initializeAnalytics(); const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Failed to find the root element'); diff --git a/scripts/check-analytics.mjs b/scripts/check-analytics.mjs new file mode 100644 index 0000000..1781679 --- /dev/null +++ b/scripts/check-analytics.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname; +const ignored = new Set(['scripts', 'node_modules']); +const templates = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !ignored.has(entry.name)) + .map((entry) => entry.name) + .filter((name) => existsSync(join(root, name, 'package.json'))) + .sort(); + +for (const name of templates) { + const directory = join(root, name); + const pkg = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + const isNext = Boolean(pkg.dependencies?.next); + const sourceRoot = existsSync(join(directory, 'src')) ? join(directory, 'src') : directory; + const helper = isNext ? join(sourceRoot, 'lib/analytics.ts') : join(directory, 'src/lib/analytics.ts'); + const bootstrap = isNext ? join(sourceRoot, 'instrumentation-client.ts') : join(directory, 'src/main.tsx'); + 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`); + + 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(bootstrapSource.includes('initializeAnalytics'), `${name}: analytics is not initialized`); + const runtimeConfigMatch = documentSource.match( + /]*)src="\/\.well-known\/insforge-runtime-config\.js"([^>]*)>/, + ); + assert(runtimeConfigMatch?.index !== undefined, `${name}: runtime config script is not loaded`); + const runtimeConfigAttributes = `${runtimeConfigMatch[1]} ${runtimeConfigMatch[2]}`; + assert(!/\b(?:async|defer)\b|type\s*=\s*["']module["']/.test(runtimeConfigAttributes), + `${name}: runtime config script must be synchronous`); + 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('POSTHOG_PROJECT_TOKEN'), `${name}: missing PostHog env documentation`); + assert(!pkg.dependencies?.['@vercel/analytics'], `${name}: do not mix Vercel and PostHog analytics`); +} + +const chatbotSources = [ + 'components/sign-in-form.tsx', + 'components/sign-up-form.tsx', + 'app/api/documents/upload/route.ts', + 'app/api/workspaces/route.ts', +].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`); +} + +console.log(`Analytics contract validated for ${templates.length} templates.`); diff --git a/todo/.env.example b/todo/.env.example index b71b016..4292fa2 100644 --- a/todo/.env.example +++ b/todo/.env.example @@ -4,3 +4,13 @@ NEXT_PUBLIC_INSFORGE_URL=https://your-app.region.insforge.app 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_INSFORGE_APP_ID= +NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= +NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=todo +NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID= +NEXT_PUBLIC_INSFORGE_RELEASE_ID= diff --git a/todo/package-lock.json b/todo/package-lock.json index 32bb078..bb11890 100644 --- a/todo/package-lock.json +++ b/todo/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@insforge/sdk": "^1.2.2", "next": "^15.5.7", + "posthog-js": "^1.425.1", "react": "^19.2.3", "react-dom": "^19.2.3" }, @@ -1000,6 +1001,31 @@ "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", @@ -1096,7 +1122,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1112,6 +1138,13 @@ "@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", @@ -2293,6 +2326,20 @@ "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", @@ -2325,7 +2372,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2486,6 +2533,15 @@ "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", @@ -3230,6 +3286,12 @@ "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", @@ -4984,6 +5046,54 @@ "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", @@ -5016,6 +5126,12 @@ "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", @@ -6152,6 +6268,19 @@ "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 da3c074..fe0d68f 100644 --- a/todo/package.json +++ b/todo/package.json @@ -11,6 +11,7 @@ "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/app/layout.tsx b/todo/src/app/layout.tsx index ce9d07e..16221a7 100644 --- a/todo/src/app/layout.tsx +++ b/todo/src/app/layout.tsx @@ -13,7 +13,9 @@ export default function RootLayout({ }>) { return ( - + diff --git a/web-research-agent/package-lock.json b/web-research-agent/package-lock.json index ccf2a2d..620dcde 100644 --- a/web-research-agent/package-lock.json +++ b/web-research-agent/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", + "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -892,6 +893,31 @@ "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", @@ -1371,14 +1397,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": 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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1395,6 +1421,13 @@ "@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", @@ -1640,6 +1673,20 @@ "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", @@ -1657,7 +1704,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1691,6 +1738,15 @@ "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", @@ -1822,6 +1878,12 @@ "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", @@ -2366,6 +2428,60 @@ "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", @@ -2931,6 +3047,19 @@ "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 39f923e..5f29b9b 100644 --- a/web-research-agent/package.json +++ b/web-research-agent/package.json @@ -11,6 +11,7 @@ "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 new file mode 100644 index 0000000..dab5de9 --- /dev/null +++ b/web-research-agent/src/lib/analytics.ts @@ -0,0 +1,116 @@ +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'] + +type RuntimeConfig = { + posthogHost?: string + posthogProjectToken?: string + applicationId?: string + environmentId?: string + templateVersionId?: string + releaseId?: string +} + +declare global { + interface Window { + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +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 +} + +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] + } + } + return sanitized +} + +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', + }), + ) + }, + }) +} + +export function track(event: string, properties?: AnalyticsProperties): void { + if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) +} + +/** 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) +} + +export function resetAnalytics(): void { + if (posthog.__loaded) posthog.reset() +} + +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'), + purchaseCompleted: (transactionId: string, value?: number, currency?: string) => + track('purchase_completed', { transaction_id: transactionId, value, currency }), +} diff --git a/web-research-agent/src/main.tsx b/web-research-agent/src/main.tsx index d8fc920..0272539 100644 --- a/web-research-agent/src/main.tsx +++ b/web-research-agent/src/main.tsx @@ -16,8 +16,11 @@ import { Sparkles } from "lucide-react"; import { connected, insforge } from "./lib/insforge"; +import { initializeAnalytics } from "./lib/analytics"; import "./index.css"; +initializeAnalytics(); + type Project = { id: string; title: string; diff --git a/website-change-monitor/.env.example b/website-change-monitor/.env.example index 6b99726..6a77987 100644 --- a/website-change-monitor/.env.example +++ b/website-change-monitor/.env.example @@ -2,3 +2,13 @@ VITE_INSFORGE_URL= VITE_INSFORGE_ANON_KEY= 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_INSFORGE_APP_ID= +VITE_INSFORGE_ENVIRONMENT_ID= +VITE_INSFORGE_TEMPLATE_ID=website-change-monitor +VITE_INSFORGE_TEMPLATE_VERSION_ID= +VITE_INSFORGE_RELEASE_ID= diff --git a/website-change-monitor/index.html b/website-change-monitor/index.html index 2385a43..ef7771f 100644 --- a/website-change-monitor/index.html +++ b/website-change-monitor/index.html @@ -8,7 +8,7 @@
- + diff --git a/website-change-monitor/package-lock.json b/website-change-monitor/package-lock.json index 0d57c74..2346ea8 100644 --- a/website-change-monitor/package-lock.json +++ b/website-change-monitor/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@insforge/sdk": "latest", "lucide-react": "^0.468.0", + "posthog-js": "^1.425.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -892,6 +893,31 @@ "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", @@ -1371,14 +1397,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": 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==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1395,6 +1421,13 @@ "@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", @@ -1640,6 +1673,20 @@ "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", @@ -1657,7 +1704,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1691,6 +1738,15 @@ "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", @@ -1822,6 +1878,12 @@ "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", @@ -2366,6 +2428,60 @@ "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", @@ -2931,6 +3047,19 @@ "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 77727ba..e73cf5f 100644 --- a/website-change-monitor/package.json +++ b/website-change-monitor/package.json @@ -11,6 +11,7 @@ "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 new file mode 100644 index 0000000..dab5de9 --- /dev/null +++ b/website-change-monitor/src/lib/analytics.ts @@ -0,0 +1,116 @@ +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'] + +type RuntimeConfig = { + posthogHost?: string + posthogProjectToken?: string + applicationId?: string + environmentId?: string + templateVersionId?: string + releaseId?: string +} + +declare global { + interface Window { + __INSFORGE_RUNTIME_CONFIG__?: RuntimeConfig + } +} + +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 +} + +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] + } + } + return sanitized +} + +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', + }), + ) + }, + }) +} + +export function track(event: string, properties?: AnalyticsProperties): void { + if (posthog.__loaded) posthog.capture(event, safeProperties(properties)) +} + +/** 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) +} + +export function resetAnalytics(): void { + if (posthog.__loaded) posthog.reset() +} + +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'), + purchaseCompleted: (transactionId: string, value?: number, currency?: string) => + track('purchase_completed', { transaction_id: transactionId, value, currency }), +} diff --git a/website-change-monitor/src/main.tsx b/website-change-monitor/src/main.tsx index 2624922..51aa93b 100644 --- a/website-change-monitor/src/main.tsx +++ b/website-change-monitor/src/main.tsx @@ -20,8 +20,11 @@ import { ShieldCheck } from "lucide-react"; import { connected, insforge } from "./lib/insforge"; +import { initializeAnalytics } from "./lib/analytics"; import "./index.css"; +initializeAnalytics(); + type Target = { id: string; name: string; diff --git a/workspace/.env.example b/workspace/.env.example index e4750d7..8f03bbd 100644 --- a/workspace/.env.example +++ b/workspace/.env.example @@ -1,3 +1,13 @@ NEXT_PUBLIC_INSFORGE_URL=https://your-project.region.insforge.app 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_INSFORGE_APP_ID= +NEXT_PUBLIC_INSFORGE_ENVIRONMENT_ID= +NEXT_PUBLIC_INSFORGE_TEMPLATE_ID=workspace +NEXT_PUBLIC_INSFORGE_TEMPLATE_VERSION_ID= +NEXT_PUBLIC_INSFORGE_RELEASE_ID= diff --git a/workspace/app/layout.tsx b/workspace/app/layout.tsx index e934ebd..6ff11fb 100644 --- a/workspace/app/layout.tsx +++ b/workspace/app/layout.tsx @@ -16,6 +16,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + +