world: native @hanzo/event telemetry — one client, subsumes Sentry + Umami

Converge world's client telemetry on the ONE canonical Hanzo Cloud front
door: pageviews, product events, and errors all leave through @hanzo/event
to POST /v1/event, lensed server-side into web analytics, product analytics,
and error tracking. This supersedes v2.4.47's fragmented client stack — the
third-party Sentry bundle and the direct analytics.hanzo.ai (Umami) page
script both fed lenses the /v1/event stream now feeds server-side, so loading
them client-side double-counted the same lenses.

- bootstrap/telemetry.ts: the one client. Framework-agnostic createAnalytics,
  brand-resolved host (hanzo/lux/zoo), IAM bearer injected at the composition
  root (else a write-only publishable key so the fail-closed door accepts
  logged-out ingest), consent gate (DNT / GPC / opt-out), SPA pageview on
  pathname change, and filtered global error capture that preserves the
  curated WebGL/map noise-filter verbatim.
- Product moments captured with the shared EVENTS vocabulary: country
  drill-down, map-layer toggle, variant switch, share, and the AI analyst
  chat funnel (start + per message). identify/group on the signed-in session.
- Remove @sentry/browser + bootstrap/sentry.ts; drop the Umami injection +
  track() from bootstrap/analytics.ts. GTM (marketing tags) stays — an
  orthogonal, env-gated concern @hanzo/event does not cover.

Logged-out marketing/public views load it too — anonymous, consent-gated.
typecheck + vite build + data tests green.
This commit is contained in:
Hanzo Dev
2026-07-22 22:45:44 -07:00
parent e6dc3f94b1
commit 4a5d753642
12 changed files with 354 additions and 218 deletions
+16
View File
@@ -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)
+19 -80
View File
@@ -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",
+1 -1
View File
@@ -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",
+4 -2
View File
@@ -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);
+7 -34
View File
@@ -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<string, unknown>) => 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<string, unknown>): 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;
-78
View File
@@ -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);
}
}
+245
View File
@@ -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<WireEvent, 'productId' | 'quantity' | 'revenue' | 'currency'>;
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<string, unknown>, commerce?: Commerce): void {
client?.capture(event, properties, commerce);
},
identify(personId: string, traits?: Record<string, unknown>): void {
client?.identify(personId, traits);
},
group(groupId: string, traits?: Record<string, unknown>): void {
client?.group(groupId, traits);
},
captureError(err: unknown, properties?: Record<string, unknown>): 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<History['pushState']>): 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);
}
@@ -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<void> {
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);
+33 -21
View File
@@ -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<void> {
// 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.
+11
View File
@@ -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<AnalystResponse> {
// 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);
+11
View File
@@ -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. */
+3 -2
View File
@@ -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;
}