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:
@@ -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)
|
||||
|
||||
Generated
+19
-80
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Vendored
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user