release: v2.4.47 — instrument world: Sentry + analytics.hanzo.ai (Umami) + GTM-ready, clean AnalyticsPanel/analytics naming

Wire world's telemetry to the live Hanzo observability services, all env-gated
from KMS (no-op until IDs provisioned — ships inert, activates on config):
- Sentry: VITE_SENTRY_DSN build-secret → errors to sentry.hanzo.ai.
- Analytics: bootstrap/analytics.ts injects the analytics.hanzo.ai (Umami) script
  on VITE_ANALYTICS_WEBSITE_ID; track() forwards custom product events (→
  /v1/insights/events + AnalyticsPanel pageviews). track('variant_view') wired.
- GTM-ready: initGtm() loads one Google Tag Manager container (VITE_GTM_ID) that
  manages GA / Facebook / LinkedIn / X tags from GTM's UI; CSP script-src allows
  googletagmanager/google-analytics/facebook/linkedin/x.
- Naming: CloudAnalyticsPanel → AnalyticsPanel, org-analytics.ts → analytics.ts.
OpenGraph/Twitter/JSON-LD SEO already comprehensive.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
This commit is contained in:
hanzo-dev
2026-07-22 18:49:21 -07:00
parent b5966ffe15
commit a17975e824
12 changed files with 71 additions and 9 deletions
+3
View File
@@ -19,6 +19,9 @@ images:
# env=prod) by hanzoai/ci — the key name IS the build-arg. Never in git.
build_secrets:
- VITE_MAPBOX_TOKEN
- VITE_SENTRY_DSN
- VITE_ANALYTICS_WEBSITE_ID
- VITE_GTM_ID
# Deploy: roll the freshly-built image onto the `world` Service CR. Fires only
# on `main` (+ tags); PRs and forks build but never deploy. KMS hands back the
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://s3.tradingview.com https://s.tradingview.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://www.youtube.com https://www.youtube-nocookie.com https://www.tradingview-widget.com https://s.tradingview.com https://www.tradingview.com;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:46123 ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.hanzo.ai https://www.youtube.com https://static.cloudflareinsights.com https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://snap.licdn.com https://static.ads-twitter.com https://s3.tradingview.com https://s.tradingview.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' http://127.0.0.1:46123 https://www.youtube.com https://www.youtube-nocookie.com https://www.tradingview-widget.com https://s.tradingview.com https://www.tradingview.com;" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<!-- Primary Meta Tags -->
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@hanzo/world",
"description": "Hanzo World — real-time global intelligence dashboard.",
"private": true,
"version": "2.4.46",
"version": "2.4.47",
"type": "module",
"scripts": {
"lint:md": "markdownlint-cli2 '**/*.md'",
+5 -2
View File
@@ -22,6 +22,7 @@ import {
MONITOR_COLORS,
} from '@/config';
import { BETA_MODE } from '@/config/beta';
import { track } from '@/bootstrap/analytics';
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 { fetchCountryMarkets } from '@/services/polymarket';
import { mlWorker } from '@/services/ml-worker';
@@ -130,7 +131,7 @@ import {
ClusterPanel,
QueuePanel,
HanzoStatusPanel,
CloudAnalyticsPanel,
AnalyticsPanel,
LlmUsagePanel,
EnsoBenchmarkPanel,
BlockchainPanel,
@@ -2542,7 +2543,7 @@ export class App {
['cloud-clusters', 'Clusters & Nodes', () => new ClusterPanel()],
['cloud-queue', 'GPU Queue', () => new QueuePanel()],
['llm-usage', 'LLM observability', () => new LlmUsagePanel()],
['cloud-analytics', 'Web analytics', () => new CloudAnalyticsPanel()],
['cloud-analytics', 'Web analytics', () => new AnalyticsPanel()],
['enso-benchmarks', 'Enso benchmarks', () => new EnsoBenchmarkPanel()],
];
@@ -2896,6 +2897,8 @@ export class App {
if (!target) return false; // unknown variant — no-op
if (target === prev) return true;
track('variant_view', { variant: target });
const cfg = variantConfig(target);
// Perf probe: the in-place switch must stay cheap (a few ms) — the whole point is
// that it does NOT cold-start. We record the synchronous cost on window for the
+48
View File
@@ -0,0 +1,48 @@
// 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.
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;
if (location.hostname.startsWith('localhost') || '__TAURI_INTERNALS__' in window) return;
if (document.querySelector('script[data-gtm]')) return;
const dl = ((window as unknown as { dataLayer?: unknown[] }).dataLayer ||= []);
dl.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
const s = document.createElement('script');
s.async = true;
s.src = `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(id)}`;
s.dataset.gtm = '1';
document.head.appendChild(s);
}
@@ -9,7 +9,7 @@ import { icon } from '@/utils/icons';
// Umami-style) across every registered site — top pages / referrers / countries,
// live visitors, pageviews. Admin-only (server enforces 403). Honest empty state
// when the analytics product has no data yet.
export class CloudAnalyticsPanel extends Panel {
export class AnalyticsPanel extends Panel {
private data: CloudAnalytics | null = null;
private loaded = false;
private timer: ReturnType<typeof setInterval> | null = null;
+1 -1
View File
@@ -7,7 +7,7 @@ import {
type AnalyticsOverview,
type AnalyticsTimeseries,
type AnalyticsTop,
} from '@/services/org-analytics';
} from '@/services/analytics';
import { escapeHtml } from '@/utils/sanitize';
import { fmtCompact, fmtInt, fmtPct, fmtUsd, statTile, sparkline, shareBar } from '@/utils/cloud-format';
+1 -1
View File
@@ -1,6 +1,6 @@
import { Panel } from './Panel';
import { isAuthenticated, login } from '@/services/iam';
import { getInsightsEvents, type InsightsEvent } from '@/services/org-analytics';
import { getInsightsEvents, type InsightsEvent } from '@/services/analytics';
import { escapeHtml } from '@/utils/sanitize';
import { fmtInt, statTile, sparkline, shareBar, fmtAgo } from '@/utils/cloud-format';
+1 -1
View File
@@ -78,7 +78,7 @@ export { CloudServicesPanel } from './CloudServicesPanel';
export { ClusterPanel } from './ClusterPanel';
export { QueuePanel } from './QueuePanel';
export { HanzoStatusPanel } from './HanzoStatusPanel';
export { CloudAnalyticsPanel } from './CloudAnalyticsPanel';
export { AnalyticsPanel } from './AnalyticsPanel';
export { LlmUsagePanel } from './LlmUsagePanel';
export { EnsoBenchmarkPanel } from './EnsoBenchmarkPanel';
export { BlockchainPanel } from './BlockchainPanel';
+7 -1
View File
@@ -1,6 +1,7 @@
import './styles/main.css';
import { App } from './App';
import type { EarlyError } from './bootstrap/sentry';
import { initAnalytics, 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
@@ -28,7 +29,11 @@ const whenIdle = (cb: () => void): void => {
else setTimeout(cb, 1200);
};
// After first paint, load Sentry off the critical path and replay any buffered errors.
// 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.
whenIdle(() => {
import('./bootstrap/sentry').then(({ initSentry }) => {
initSentry(earlyErrors);
@@ -36,6 +41,7 @@ whenIdle(() => {
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 */ }
});
import { debugInjectTestEvents, debugGetCells, getCellCount } from '@/services/geo-convergence';
+2
View File
@@ -4,4 +4,6 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_SENTRY_DSN?: string;
readonly VITE_ANALYTICS_WEBSITE_ID?: string;
readonly VITE_GTM_ID?: string;
}