diff --git a/.env.example b/.env.example index 119be915..8d9d96f5 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,22 @@ VITE_WS_RELAY_URL= # WORLDPOP_API_KEY= +# ------ Telemetry (Hanzo Cloud — @hanzo/event) ------ + +# Telemetry (pageviews, product events, errors) posts to api.hanzo.ai/v1/event, +# fanned server-side into web analytics, product analytics, and error tracking. +# Signed-in visitors are attributed by their IAM bearer automatically. To also +# accept LOGGED-OUT / anonymous traffic on the fail-closed door, ship a write-only +# publishable ingest key (pk_live_…). It is safe to bundle (cannot read, only +# ingest). Mint one per org via POST /v1/ingest/keys. Absent → anonymous events +# are best-effort (dropped by the door unless it allows unauthenticated ingest). +VITE_HANZO_INGEST_KEY= + +# Google Tag Manager container id (marketing tags — GA / ad pixels). Orthogonal +# to the telemetry above; no-op until provisioned from KMS. +# VITE_GTM_ID= + + # ------ Site Configuration ------ # Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app) diff --git a/package-lock.json b/package-lock.json index 5a1bf603..e606d0b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "world-monitor", - "version": "2.4.38", + "name": "@hanzo/world", + "version": "2.4.47", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "world-monitor", - "version": "2.4.38", + "name": "@hanzo/world", + "version": "2.4.47", "dependencies": { "@deck.gl/aggregation-layers": "^9.2.6", "@deck.gl/core": "^9.2.6", @@ -14,7 +14,7 @@ "@deck.gl/layers": "^9.2.6", "@deck.gl/mapbox": "^9.2.6", "@hanzo/ai": "^0.2.1", - "@sentry/browser": "^10.39.0", + "@hanzo/event": "^0.3.1", "@upstash/redis": "^1.36.1", "@xenova/transformers": "^2.17.2", "d3": "^7.9.0", @@ -2611,6 +2611,20 @@ "node": ">=18" } }, + "node_modules/@hanzo/event": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@hanzo/event/-/event-0.3.1.tgz", + "integrity": "sha512-FAA6C/+hP8woUdle9VxRZXreCpCa+K1zlZAVOT8HTYHu6QH37LJIG2KS5NXutx80haSwNPGooJJlZRvx9x07ug==", + "license": "BSD-3-Clause", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/@huggingface/jinja": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", @@ -3887,81 +3901,6 @@ "win32" ] }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz", - "integrity": "sha512-W6WODonMGiI13Az5P7jd/m2lj/JpIyuVKg7wE4X+YdlMehLspAv6I7gRE4OBSumS14ZjdaYDpD/lwtnBwKAzcA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.39.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.39.0.tgz", - "integrity": "sha512-cRXmmDeOr5FzVsBNRLU4WDEuC3fhuD0XV362EWl4DI3XBGao8ukaueKcLIKic5WZx6uXimjWw/UJmDLgxeCqkg==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.39.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.39.0.tgz", - "integrity": "sha512-obZoYOrUfxIYBHkmtPpItRdE38VuzF1VIxSgZ8Mbtq/9UvCWh+eOaVWU2stN/cVu1KYuYX0nQwBvdN28L6y/JA==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.39.0", - "@sentry/core": "10.39.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.39.0.tgz", - "integrity": "sha512-TTiX0XWCcqTqFGJjEZYObk93j/sJmXcqPzcu0cN2mIkKnnaHDY3w74SHZCshKqIr0AOQdt1HDNa36s3TCdt0Jw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.39.0", - "@sentry/core": "10.39.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.39.0.tgz", - "integrity": "sha512-I50W/1PDJWyqgNrGufGhBYCmmO3Bb159nx2Ut2bKoVveTfgH/hLEtDyW0kHo8Fu454mW+ukyXfU4L4s+kB9aaw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.39.0", - "@sentry-internal/feedback": "10.39.0", - "@sentry-internal/replay": "10.39.0", - "@sentry-internal/replay-canvas": "10.39.0", - "@sentry/core": "10.39.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/core": { - "version": "10.39.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz", - "integrity": "sha512-xCLip2mBwCdRrvXHtVEULX0NffUTYZZBhEUGht0WFL+GNdNQ7gmBOGOczhZlrf2hgFFtDO0fs1xiP9bqq5orEQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", diff --git a/package.json b/package.json index b8fe41dd..586459d5 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@deck.gl/layers": "^9.2.6", "@deck.gl/mapbox": "^9.2.6", "@hanzo/ai": "^0.2.1", - "@sentry/browser": "^10.39.0", + "@hanzo/event": "^0.3.1", "@upstash/redis": "^1.36.1", "@xenova/transformers": "^2.17.2", "d3": "^7.9.0", diff --git a/src/App.ts b/src/App.ts index 8733dda7..d4f63741 100644 --- a/src/App.ts +++ b/src/App.ts @@ -21,7 +21,7 @@ import { isLuxFundHost, MONITOR_COLORS, } from '@/config'; -import { track } from '@/bootstrap/analytics'; +import { telemetry, EVENTS } from '@/bootstrap/telemetry'; import { fetchCategoryFeeds, getFeedFailures, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, fetchChinaMacro, fetchCyberThreats, drainTrendingSignals } from '@/services'; import { mlWorker } from '@/services/ml-worker'; import { attachPanelDrag, attachPanelResize, attachPanelColResize } from '@/services/panel-drag'; @@ -2371,6 +2371,7 @@ export class App { private setMapLayerEnabled(key: string, on: boolean): boolean { if (!(key in this.mapLayers)) return false; + telemetry.capture(EVENTS.FEATURE_USED, { feature: 'map_layer', layer: key, on }); const layer = key as keyof MapLayers; this.mapLayers[layer] = on; saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers); @@ -2409,7 +2410,7 @@ export class App { if (!target) return false; // unknown variant — no-op if (target === prev) return true; - track('variant_view', { variant: target }); + telemetry.capture(EVENTS.FEATURE_USED, { feature: 'variant', variant: target }); const cfg = variantConfig(target); // Perf probe: the in-place switch must stay cheap (a few ms) — the whole point is @@ -2680,6 +2681,7 @@ export class App { const button = document.getElementById('copyLinkBtn'); try { await this.copyToClipboard(shareUrl); + telemetry.capture(EVENTS.FEATURE_USED, { feature: 'share_link' }); this.setCopyLinkFeedback(button, 'Copied!'); } catch (error) { console.warn('Failed to copy share link:', error); diff --git a/src/bootstrap/analytics.ts b/src/bootstrap/analytics.ts index 8fbaba56..a25f0937 100644 --- a/src/bootstrap/analytics.ts +++ b/src/bootstrap/analytics.ts @@ -1,38 +1,11 @@ -// Product analytics, code-split out of the entry chunk (loaded lazily from -// main.ts once the browser is idle, like Sentry). Injects the analytics.hanzo.ai -// tracking script, guarded on a build-time website id so it stays a no-op until -// one is provisioned from KMS (VITE_ANALYTICS_WEBSITE_ID). track() forwards -// custom product events to the same collector and no-ops when the script or id -// is absent, so call sites never branch on availability. +// Marketing tags — Google Tag Manager, one env-gated container that manages the +// GA / Facebook / LinkedIn / X marketing tags from GTM's UI (no per-pixel code). +// This is an orthogonal concern to first-party product/web/error telemetry, which +// flows through @hanzo/event (see bootstrap/telemetry.ts) to the ONE Hanzo Cloud +// door and is fanned server-side into the web-analytics lens (analytics.hanzo.ai), +// so the app never loads that collector's page script directly. No-op until +// VITE_GTM_ID is provisioned from KMS. -declare global { - interface Window { - umami?: { track?: (name: string, data?: Record) => void }; - } -} - -export function initAnalytics(): void { - const id = import.meta.env.VITE_ANALYTICS_WEBSITE_ID; - if (!id) return; - // Same enable guard as Sentry: never POST to the shared collector from a dev - // machine (localhost) or the offline Tauri desktop shell. - if (location.hostname.startsWith('localhost') || '__TAURI_INTERNALS__' in window) return; - if (document.querySelector('script[data-website-id]')) return; - const s = document.createElement('script'); - s.defer = true; - s.src = 'https://analytics.hanzo.ai/script.js'; - s.dataset.websiteId = id; - document.head.appendChild(s); -} - -/** Forward a custom product event to analytics.hanzo.ai. No-op until the script loads. */ -export function track(name: string, data?: Record): void { - window.umami?.track?.(name, data); -} - -/** Google Tag Manager container — one env-gated container that manages the GA / - * Facebook / LinkedIn / X marketing tags from GTM's UI (no per-pixel code). No-op - * until VITE_GTM_ID is provisioned from KMS. */ export function initGtm(): void { const id = import.meta.env.VITE_GTM_ID; if (!id) return; diff --git a/src/bootstrap/sentry.ts b/src/bootstrap/sentry.ts deleted file mode 100644 index 8f21ed3d..00000000 --- a/src/bootstrap/sentry.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Sentry, code-split out of the entry chunk. -// -// @sentry/browser is ~460 KB raw / ~130 KB gzip — dead weight in the critical -// path on a low-end laptop, where every eager KB is main-thread parse/compile -// time before first paint. main.ts imports this lazily once the browser is idle -// (see main.ts), so error tracking still installs within the first second but -// never blocks the shell or the map from painting. A tiny synchronous error -// buffer in main.ts captures anything thrown before this loads and replays it. - -import * as Sentry from '@sentry/browser'; - -export interface EarlyError { - error?: unknown; - message?: string; -} - -export function initSentry(buffered: EarlyError[] = []): void { - // DSN is provisioned at build time from KMS (VITE_SENTRY_DSN). With none set, - // stay a no-op rather than POSTing to a stale endpoint (403 on every event). - const dsn = import.meta.env.VITE_SENTRY_DSN; - if (!dsn) return; - Sentry.init({ - dsn, - release: `hanzo-world@${__APP_VERSION__}`, - environment: location.hostname === 'world.hanzo.ai' ? 'production' - : location.hostname.includes('vercel.app') ? 'preview' - : 'development', - enabled: !location.hostname.startsWith('localhost') && !('__TAURI_INTERNALS__' in window), - sendDefaultPii: true, - tracesSampleRate: 0.1, - ignoreErrors: [ - 'Invalid WebGL2RenderingContext', - 'WebGL context lost', - /reading 'imageManager'/, - /ResizeObserver loop/, - /NotAllowedError/, - /InvalidAccessError/, - /importScripts/, - /^TypeError: Load failed$/, - /^TypeError: Failed to fetch( \(.*\))?$/, - /^TypeError: cancelled$/, - /^TypeError: NetworkError/, - /runtime\.sendMessage\(\)/, - /Java object is gone/, - /^Object captured as promise rejection with keys:/, - /Unable to load image/, - /Non-Error promise rejection captured with value:/, - /Connection to Indexed Database server lost/, - /webkit\.messageHandlers/, - /unsafe-eval.*Content Security Policy/, - /Fullscreen request denied/, - /requestFullscreen/, - /vc_text_indicators_context/, - /Program failed to link: null/, - /too much recursion/, - ], - beforeSend(event) { - const msg = event.exception?.values?.[0]?.value ?? ''; - if (msg.length <= 3 && /^[a-zA-Z_$]+$/.test(msg)) return null; - const frames = event.exception?.values?.[0]?.stacktrace?.frames ?? []; - // Suppress module-import failures only when originating from browser extensions - if (/Importing a module script failed/.test(msg)) { - if (frames.some(f => /^(chrome|moz)-extension:/.test(f.filename ?? ''))) return null; - } - // Suppress maplibre internal null-access crashes (light, placement) only when stack is in map chunk - if (/this\.style\._layers|this\.light is null|can't access property "type", \w+ is undefined|Cannot read properties of null \(reading '(id|type)'\)/.test(msg)) { - if (frames.some(f => /\/map-[A-Za-z0-9]+\.js/.test(f.filename ?? ''))) return null; - } - return event; - }, - }); - - // Replay anything the synchronous boot buffer caught before we loaded. - for (const e of buffered) { - if (e.error !== undefined) Sentry.captureException(e.error); - else if (e.message) Sentry.captureMessage(e.message); - } -} diff --git a/src/bootstrap/telemetry.ts b/src/bootstrap/telemetry.ts new file mode 100644 index 00000000..f6810667 --- /dev/null +++ b/src/bootstrap/telemetry.ts @@ -0,0 +1,245 @@ +// The ONE telemetry client for Hanzo World. +// +// Every kind of signal — pageview, product event, identify/group, AND errors — +// leaves the app through a single client (@hanzo/event) to the ONE Hanzo Cloud +// front door: +// +// POST {host}/v1/event body: { batch: [Event, …] } -> { accepted, dropped } +// +// Cloud resolves the tenant server-side (from the validated bearer, never from a +// field the client sends) and fans the one stream into three lenses: web +// analytics (analytics.hanzo.ai), product analytics (insights.hanzo.ai), and +// error tracking (sentry.hanzo.ai). Errors are just `type:'error'` events on the +// same pipe, so this subsumes both the standalone @sentry client and a direct +// analytics.hanzo.ai (Umami) page-script — one door, fanned out server-side. +// +// @hanzo/event is dependency-free and a few KB, so — unlike the old ~460 KB +// Sentry bundle — it rides the entry chunk with negligible parse cost and starts +// capturing from the first line, no lazy split needed. + +import { createAnalytics, EVENTS, type Analytics, type WireEvent } from '@hanzo/event'; + +export { EVENTS }; + +/** An error caught by the synchronous boot buffer before the client is live. */ +export interface EarlyError { + error?: unknown; + message?: string; +} + +type Commerce = Pick; + +const isBrowser = (): boolean => typeof window !== 'undefined' && typeof document !== 'undefined'; + +// A write-only publishable ingest key (pk_…), if the deployment ships one. It is +// safe to bundle (cannot read, only ingest) and lets logged-out visitors reach +// the fail-closed door: the door HMAC-verifies it to an org, so anonymous +// marketing/public pageviews + errors are accepted with no session. Mint via +// POST /v1/ingest/keys. Absent → logged-out events post anonymously (best-effort). +function ingestKey(): string | undefined { + const k = import.meta.env.VITE_HANZO_INGEST_KEY; + return k && k.trim() ? k.trim() : undefined; +} + +// Cloud front door by brand — mirrors services/iam.ts issuer resolution so a Lux +// or Zoo white-label fork sends its telemetry to its own cloud, never Hanzo's. +function resolveHost(): string { + const h = isBrowser() ? location.hostname : ''; + if (h.endsWith('lux.network') || h.endsWith('lux.id')) return 'https://api.lux.network'; + if (h.endsWith('zoo.ngo') || h.endsWith('zoo.network') || h.endsWith('zoo.id')) return 'https://api.zoo.network'; + return 'https://api.hanzo.ai'; +} + +// Telemetry runs on the deployed site only — never localhost dev or the Tauri +// desktop shell (matches the prior Sentry/analytics enable policy). +function enabledEnv(): boolean { + if (!isBrowser()) return false; + const h = location.hostname; + if (h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '[::1]') return false; + if ('__TAURI_INTERNALS__' in window) return false; + return true; +} + +// Consent gate: honor Do-Not-Track / Global Privacy Control and an explicit +// opt-out. The client is anonymous and tenant-safe by default (a random visitor +// id, first-touch attribution, no org) so there is no PII to leak; a signed-in +// session only adds the user's stable id via identify(). +function consented(): boolean { + if (!isBrowser()) return false; + const nav = navigator as Navigator & { + doNotTrack?: string | null; + msDoNotTrack?: string | null; + globalPrivacyControl?: boolean; + }; + const dnt = nav.doNotTrack ?? (window as unknown as { doNotTrack?: string }).doNotTrack ?? nav.msDoNotTrack; + if (dnt === '1' || dnt === 'yes' || nav.globalPrivacyControl === true) return false; + try { + if (localStorage.getItem('hanzo-telemetry-optout') === '1') return false; + } catch { + /* private mode — treat as consented */ + } + return true; +} + +// Curated error noise-filter, preserved verbatim from the Sentry setup this +// client replaces: a WebGL + map dashboard throws a steady stream of non- +// actionable browser/graphics errors that would otherwise drown the error lens. +const IGNORE: (RegExp | string)[] = [ + 'Invalid WebGL2RenderingContext', + 'WebGL context lost', + /reading 'imageManager'/, + /ResizeObserver loop/, + /NotAllowedError/, + /InvalidAccessError/, + /importScripts/, + /^TypeError: Load failed$/, + /^TypeError: Failed to fetch( \(.*\))?$/, + /^TypeError: cancelled$/, + /^TypeError: NetworkError/, + /runtime\.sendMessage\(\)/, + /Java object is gone/, + /^Object captured as promise rejection with keys:/, + /Unable to load image/, + /Non-Error promise rejection captured with value:/, + /Connection to Indexed Database server lost/, + /webkit\.messageHandlers/, + /unsafe-eval.*Content Security Policy/, + /Fullscreen request denied/, + /requestFullscreen/, + /vc_text_indicators_context/, + /Program failed to link: null/, + /too much recursion/, +]; + +const MODULE_IMPORT = /Importing a module script failed/; +const EXTENSION_FRAME = /^(chrome|moz)-extension:/m; +const MAP_NULL = /this\.style\._layers|this\.light is null|can't access property "type", \w+ is undefined|Cannot read properties of null \(reading '(id|type)'\)/; +const MAP_CHUNK = /\/map-[A-Za-z0-9]+\.js/; + +function describe(err: unknown, fallback?: string): { primary: string; hay: string; stack: string } { + if (err instanceof Error) { + const composed = `${err.name}: ${err.message}`; + return { primary: err.message, hay: `${err.message}\n${composed}\n${String(err)}`, stack: err.stack ?? '' }; + } + if (typeof err === 'string') return { primary: err, hay: err, stack: '' }; + let s: string; + try { + s = JSON.stringify(err); + } catch { + s = String(err); + } + const primary = fallback ?? s; + return { primary, hay: `${primary}\n${s}`, stack: '' }; +} + +/** shouldDrop replicates the prior Sentry ignoreErrors + beforeSend filter: + * unconditional noise, minified single-token junk, extension-origin module + * import failures, and maplibre internal null-access from the map chunk. */ +function shouldDrop(err: unknown, fallback?: string, filename?: string): boolean { + const { primary, hay, stack } = describe(err, fallback); + if (primary.length <= 3 && /^[a-zA-Z_$]+$/.test(primary)) return true; + if (IGNORE.some((p) => (typeof p === 'string' ? hay.includes(p) : p.test(hay)))) return true; + const frames = filename ? `${stack}\n${filename}` : stack; + if (MODULE_IMPORT.test(hay) && EXTENSION_FRAME.test(frames)) return true; + if (MAP_NULL.test(hay) && MAP_CHUNK.test(frames)) return true; + return false; +} + +let client: Analytics | null = null; + +/** The app-facing telemetry surface. Every method is a no-op when telemetry is + * uninstalled or gated off, so call sites never guard. */ +export const telemetry = { + pageview(path?: string): void { + client?.pageview(path); + }, + capture(event: string, properties?: Record, commerce?: Commerce): void { + client?.capture(event, properties, commerce); + }, + identify(personId: string, traits?: Record): void { + client?.identify(personId, traits); + }, + group(groupId: string, traits?: Record): void { + client?.group(groupId, traits); + }, + captureError(err: unknown, properties?: Record): void { + if (shouldDrop(err)) return; + client?.captureError(err, { handled: true, properties }); + }, +}; + +// Paths that are transient redirect landings, not real pages — no pageview. +const IGNORE_PATHS = new Set(['/auth/callback']); + +// Fire a pageview only when the path changes. World rewrites the query string +// heavily (share URLs, ?country=) via pushState/replaceState — those are state, +// not navigation, and the interactions they reflect are captured as product +// events instead, so keying pageviews on pathname keeps the web-analytics count +// honest (one per real page, not per URL churn). +function installPageviews(): void { + let lastPath = location.pathname; + const fire = (): void => { + if (location.pathname === lastPath || IGNORE_PATHS.has(location.pathname)) return; + lastPath = location.pathname; + telemetry.pageview(location.pathname); + }; + const patch = (name: 'pushState' | 'replaceState'): void => { + const orig = history[name]; + history[name] = function patched(this: History, ...args: Parameters): void { + orig.apply(this, args); + fire(); + } as History['pushState']; + }; + patch('pushState'); + patch('replaceState'); + window.addEventListener('popstate', fire); +} + +// Global error + rejection capture — the drop-in @sentry replacement. The client +// runs with captureErrors:false so these filtered handlers are the ONE error +// path; each survivor becomes a type:'error' event Cloud stamps event_type='error' +// → the sentry.hanzo.ai lens. +function installErrorHandlers(buffered: EarlyError[]): void { + window.addEventListener('error', (e: ErrorEvent) => { + const err = e.error ?? e.message; + if (!shouldDrop(err, e.message, e.filename)) client?.captureError(err, { handled: false }); + }); + window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => { + if (!shouldDrop(e.reason)) client?.captureError(e.reason, { handled: false }); + }); + // Replay anything the synchronous boot buffer caught before we loaded. + for (const e of buffered) { + if (e.error !== undefined) { + if (!shouldDrop(e.error)) client?.captureError(e.error, { handled: false }); + } else if (e.message && !shouldDrop(e.message)) { + client?.captureError(e.message, { handled: false }); + } + } +} + +/** installTelemetry wires the ONE client: the bearer getter is injected by the + * composition root (main.ts) so this module stays decoupled from IAM. Idempotent, + * browser-only, and inert when gated off (dev/desktop/DNT/opt-out) — logged-out + * marketing views still get pageviews + errors, anonymously. */ +export function installTelemetry(opts: { getToken?: () => string | undefined; earlyErrors?: EarlyError[] } = {}): void { + if (client || !isBrowser()) return; + if (!enabledEnv() || !consented()) return; + + // Auth precedence per event: the signed-in user's bearer (Cloud resolves their + // tenant), else the publishable key (anonymous org). Both ride Authorization: + // Bearer on fetch — so the unload keepalive flush carries auth either way — + // rather than the config `ingestKey`, which would statically override the user + // bearer and mis-attribute signed-in events to the publishable org. + const bearer = opts.getToken; + const pk = ingestKey(); + client = createAnalytics({ + product: 'world', + host: resolveHost(), + getToken: () => bearer?.() ?? pk, + captureErrors: false, // the filtered handlers below are the one error path + }); + client.init(); + installErrorHandlers(opts.earlyErrors ?? []); + installPageviews(); + if (!IGNORE_PATHS.has(location.pathname)) telemetry.pageview(location.pathname); +} diff --git a/src/controllers/CountryIntelController.ts b/src/controllers/CountryIntelController.ts index 4895e2e9..7d1931fb 100644 --- a/src/controllers/CountryIntelController.ts +++ b/src/controllers/CountryIntelController.ts @@ -15,6 +15,7 @@ import { reverseGeocode } from '@/utils/reverse-geocode'; import { fetchCountryMarkets } from '@/services/polymarket'; import { dataFreshness } from '@/services/data-freshness'; import { mlWorker } from '@/services/ml-worker'; +import { telemetry, EVENTS } from '@/bootstrap/telemetry'; import { BETA_MODE } from '@/config/beta'; type IntlDisplayNamesCtor = new ( @@ -173,6 +174,9 @@ export class CountryIntelController { async openCountryBriefByCode(code: string, country: string): Promise { if (!this.countryBriefPage) return; + // The one funnel every country drill-down passes through (click, deep link, + // search, history restore) — the marquee product interaction. + telemetry.capture(EVENTS.FEATURE_USED, { feature: 'country_view', country: code }); const wasVisible = this.countryBriefPage.isVisible(); // [country-view] push vs replace this.deps.getMap()?.setRenderPaused(true); diff --git a/src/main.ts b/src/main.ts index a2663be6..17d325d0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,14 +1,16 @@ import './styles/main.css'; import { App } from './App'; -import type { EarlyError } from './bootstrap/sentry'; -import { initAnalytics, initGtm } from './bootstrap/analytics'; +import { installTelemetry, telemetry, type EarlyError } from '@/bootstrap/telemetry'; +import { initGtm } from './bootstrap/analytics'; -// Telemetry (Sentry — ~460 KB raw / ~130 KB gzip) is code-split out of the entry -// chunk. On a low-end laptop every eager KB is main-thread parse/compile time -// before first paint; deferring Sentry keeps the shell + map off the critical -// path. Error tracking still installs within the first second (whenIdle, below), -// and a tiny synchronous buffer captures anything thrown before it loads and -// replays it — so no early boot error is lost. +// The ONE telemetry client (@hanzo/event): pageviews, product events, and errors +// all leave through POST /v1/event to Hanzo Cloud, lensed server-side into web +// analytics, product analytics, and error tracking. It subsumes both the old +// ~460 KB third-party Sentry client and the direct analytics.hanzo.ai page +// script — one door, fanned out server-side. It is dependency-free and tiny, so +// it rides the entry chunk and starts capturing immediately; a synchronous +// buffer still catches anything thrown before install and replays it, so no +// early boot error is lost. const earlyErrors: EarlyError[] = []; const bufferError = (e: ErrorEvent | PromiseRejectionEvent): void => { const err = (e as ErrorEvent).error ?? (e as PromiseRejectionEvent).reason ?? (e as ErrorEvent).message; @@ -29,25 +31,26 @@ const whenIdle = (cb: () => void): void => { else setTimeout(cb, 1200); }; -// After first paint, install telemetry off the critical path: Sentry (heavy, so -// code-split and dynamically imported here, replaying any buffered errors) and -// product analytics (tiny — track() is used synchronously by App so it lives in -// the main chunk; only its script-injecting init is deferred). Both are env-gated -// no-ops until their IDs are provisioned, and best-effort so they never break boot. +// Install the ONE telemetry client eagerly — it is dependency-free and tiny, so +// unlike the old Sentry bundle it need not wait for idle, and installing now +// captures boot-time errors live. The bearer getter is injected so telemetry +// stays decoupled from IAM; signed-out visitors are captured anonymously. The +// synchronous buffer's errors are replayed inside, then its listeners retire. +installTelemetry({ getToken: accessToken, earlyErrors }); +window.removeEventListener('error', bufferError); +window.removeEventListener('unhandledrejection', bufferError); +earlyErrors.length = 0; + +// Marketing tags (Google Tag Manager) are an orthogonal, env-gated concern the +// telemetry client does not cover — defer the external script off first paint. whenIdle(() => { - import('./bootstrap/sentry').then(({ initSentry }) => { - initSentry(earlyErrors); - window.removeEventListener('error', bufferError); - window.removeEventListener('unhandledrejection', bufferError); - earlyErrors.length = 0; - }).catch(() => { /* telemetry is best-effort — never break boot */ }); - try { initAnalytics(); initGtm(); } catch { /* telemetry is best-effort — never break boot */ } + try { initGtm(); } catch { /* best-effort — never break boot */ } }); import { debugInjectTestEvents, debugGetCells, getCellCount } from '@/services/geo-convergence'; import { initMetaTags } from '@/services/meta-tags'; import { installRuntimeFetchPatch } from '@/services/runtime'; -import { isCallback, handleCallback } from '@/services/iam'; +import { isCallback, handleCallback, accessToken, getUser } from '@/services/iam'; import { initDashboardSync } from '@/services/dashboard'; import { loadDesktopSecrets } from '@/services/runtime-config'; import { applyStoredTheme } from '@/utils/theme-manager'; @@ -94,6 +97,15 @@ async function boot(): Promise { // changes so they auto-sync. Anonymous is untouched. Best-effort, never blocks boot. await initDashboardSync(); + // Bind the signed-in identity to the telemetry stream so product events are + // attributed to the person + their org (cached; anonymous stays anonymous). + // Best-effort — telemetry never blocks or breaks boot. + void getUser().then((u) => { + if (!u) return; + telemetry.identify(u.sub); + if (u.owner) telemetry.group(u.owner); + }).catch(() => { /* best effort */ }); + const app = new App('app'); // Dev/e2e observability: expose the running App so tests can drive layout // invariants (e.g. the full-width-map anchor heal) without any production hook. diff --git a/src/services/analyst.ts b/src/services/analyst.ts index f07de76d..f17702f0 100644 --- a/src/services/analyst.ts +++ b/src/services/analyst.ts @@ -20,6 +20,7 @@ import { getTrafficGlobe } from './cloud-map'; import { getCloudPulse, getMyBilling } from './cloud-pulse'; import { getRouterStats } from './router-stats'; import { getEnsoTraining } from './enso-training'; +import { telemetry, EVENTS } from '@/bootstrap/telemetry'; // The grounding snapshot is best-effort garnish. Cap each read so a cold server // (gdelt-doc can take ~10s cold) never delays the user's chat send. @@ -36,12 +37,22 @@ export type { AnalystResponse }; * to its default when empty. The command manifest travels with every request. * With `live` handlers the answer STREAMS (SSE) when the transport supports it; * the resolved response is identical either way. */ +// Session-scoped: the first askAnalyst opens the chat funnel (CHAT_STARTED once). +let chatStarted = false; + export async function askAnalyst( messages: AnalystMessage[], context: string, model?: string, live?: AnalystLiveHandlers, ): Promise { + // AI analyst funnel: the first turn opens a chat, every turn is a message. No + // prompt content leaves the app — only the turn count + chosen model. + if (!chatStarted) { + chatStarted = true; + telemetry.capture(EVENTS.CHAT_STARTED, { surface: 'analyst' }); + } + telemetry.capture(EVENTS.CHAT_MESSAGE_SENT, { surface: 'analyst', turns: messages.length, model: model || 'default' }); const t = analystTransport(); const req = { messages, context, commands: commandManifest(), model }; return live && t.askStream ? t.askStream(req, live) : t.ask(req); diff --git a/src/services/iam.ts b/src/services/iam.ts index f994b928..d48f9ddd 100644 --- a/src/services/iam.ts +++ b/src/services/iam.ts @@ -218,6 +218,17 @@ export function isAuthenticated(): boolean { return !!localStorage.getItem(K.access); } +/** The current access token, read synchronously (no refresh). undefined when + * signed out. For callers that prove tenancy best-effort — a possibly-stale + * token is fine (telemetry) — not for authoritative API calls (use getToken). */ +export function accessToken(): string | undefined { + try { + return localStorage.getItem(K.access) ?? undefined; + } catch { + return undefined; + } +} + let cachedUser: IamUser | null = null; /** Fetch the OIDC userinfo (cached for the session). null if signed out. */ diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2550743f..348c8c5a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,7 +3,8 @@ declare const __APP_VERSION__: string; interface ImportMetaEnv { - readonly VITE_SENTRY_DSN?: string; - readonly VITE_ANALYTICS_WEBSITE_ID?: string; + // Write-only publishable ingest key for anonymous /v1/event ingest (@hanzo/event). + readonly VITE_HANZO_INGEST_KEY?: string; + // Google Tag Manager container id (marketing tags — orthogonal to telemetry). readonly VITE_GTM_ID?: string; }