telemetry: move chat onto the one door (/v1/event) + observe autocapture

- @hanzo/event ^0.2.0 -> ^0.3.1: chat now posts to the canonical front door
  (POST /v1/event) instead of legacy /v1/analytics + /v1/tracker.
- Mount @hanzo/observe (ObserveProvider) on the SAME client: default-on
  interaction autocapture ($click/$input/$change/$submit) with a semantic DOM
  hierarchy; input values redacted by default (PII-free). nav={false} so the
  event layer stays the single pageview counter (no double-count).
- Consent-gated: honor Do Not Track / Global Privacy Control (enabled), applied
  to the client and to autocapture.
- Ingest key: read VITE_HANZO_INGEST_KEY so logged-out / guest views emit
  accepted telemetry (chat has a first-class guest surface).
- Remove the legacy direct tags from index.html: the analytics.hanzo.ai Umami
  script and the inline PostHog Insights snippet (CSP-blocked + key-gated dead
  code) — the one @hanzo/event client replaces both. Drop analytics.hanzo.ai
  from the script-src CSP (connect-src already allows *.hanzo.ai for /v1/event).
- Document VITE_HANZO_INGEST_KEY / VITE_HANZO_ANALYTICS_HOST in .env.example.
This commit is contained in:
Hanzo Dev
2026-07-23 02:50:46 -07:00
parent 256595062b
commit 7d15a15217
6 changed files with 91 additions and 25 deletions
+9
View File
@@ -737,6 +737,15 @@ HELP_AND_FAQ_URL=https://hanzo.ai/chat
# Google tag manager id
#ANALYTICS_GTM_ID=user provided google tag manager id
# Hanzo telemetry (the one @hanzo/event client → POST api.hanzo.ai/v1/event).
# Build-time (VITE_) vars, baked into the client bundle.
# VITE_HANZO_INGEST_KEY: publishable ingest key (pk_…), write-only, safe to ship.
# Lets logged-out / guest visitors emit accepted pageviews + errors (Cloud stamps
# the org from the key). Provision one per org via POST /v1/ingest/keys.
# VITE_HANZO_ANALYTICS_HOST: override the front-door host (default https://api.hanzo.ai).
#VITE_HANZO_INGEST_KEY=pk_live_xxx
#VITE_HANZO_ANALYTICS_HOST=https://api.hanzo.ai
# limit conversation file imports to a certain number of bytes in size to avoid the container
# maxing out memory limitations by unremarking this line and supplying a file size in bytes
# such as the below example of 250 mib
+1 -1
View File
@@ -136,7 +136,7 @@ const startServer = async () => {
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.hanzo.ai https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' https://*.hanzo.ai https://*.hanzo.chat wss://*.hanzo.chat https://static.cloudflareinsights.com https://cloudflareinsights.com; frame-ancestors 'none';",
);
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
+1 -12
View File
@@ -45,18 +45,7 @@
document.head.appendChild(loadingContainerStyle);
</script>
<script defer type="module" src="/src/main.jsx"></script>
<!-- Hanzo Analytics -->
<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="%VITE_ANALYTICS_SITE_ID%"></script>
<!-- Hanzo Insights (Insights) - only loaded when VITE_INSIGHTS_KEY is set -->
<script>
(function() {
var key = "%VITE_INSIGHTS_KEY%";
var host = "%VITE_INSIGHTS_HOST%" || "https://insights.hanzo.ai";
if (!key || key === "%VITE_INSIGHTS_KEY%") return;
!function(t,e){var o,n,p,r;e.__SV||(window.insights=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="insights",u.people=u.people||[],u.toString=function(t){var e="insights";return"insights"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+" (stub)"},o="capture identify alias reset opt_in_capturing opt_out_capturing has_opted_out_capturing has_opted_in_capturing register register_once unregister".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.insights||(window.insights=[]));
insights.init(key, {api_host: host, person_profiles: 'identified_only'});
})();
</script>
<!-- Telemetry is the one @hanzo/event client (POST /v1/event), mounted in AnalyticsProvider — no direct third-party tags. -->
</head>
<body>
<div id="root">
+2 -1
View File
@@ -35,9 +35,10 @@
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",
"@hanzo/ai": "^0.2.1",
"@hanzo/event": "^0.2.0",
"@hanzo/event": "^0.3.1",
"@hanzo/iam": "^0.13.1",
"@hanzo/logo": "^1.0.13",
"@hanzo/observe": "^0.1.0",
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
"@hanzo/usage": "^0.1.6",
"@hanzochat/client": "workspace:*",
+41 -6
View File
@@ -6,10 +6,32 @@ import {
useAnalytics,
usePageview,
} from '@hanzo/event/react';
import { ObserveProvider } from '@hanzo/observe/react';
import { useAuthContext } from '~/hooks/AuthContext';
const ANALYTICS_HOST = import.meta.env.VITE_HANZO_ANALYTICS_HOST || 'https://api.hanzo.ai';
/** Publishable ingest key (pk_…) — write-only, safe to ship in the bundle. It lets
* logged-out / guest visitors reach the ONE front door (POST /v1/event) so their
* pageviews + errors land server-side (Cloud stamps the org from the key). Chat has
* a first-class guest surface, so this is what makes anonymous guest telemetry
* accepted; provision one per org via POST /v1/ingest/keys. */
const INGEST_KEY = import.meta.env.VITE_HANZO_INGEST_KEY || undefined;
/** Consent gate — honor Do Not Track / Global Privacy Control as opt-out. The client
* sends no PII (a stable user id only, never email), so the browser's standard
* privacy signals are the whole consent surface. Opting out suppresses pageviews,
* events, errors, AND interaction autocapture. */
function telemetryEnabled(): boolean {
if (typeof navigator === 'undefined') return true;
const n = navigator as Navigator & { msDoNotTrack?: string | null; globalPrivacyControl?: boolean };
const w = typeof window !== 'undefined' ? (window as Window & { doNotTrack?: string | null }) : undefined;
const dnt = n.doNotTrack ?? w?.doNotTrack ?? n.msDoNotTrack;
if (dnt === '1' || dnt === 'yes') return false;
if (n.globalPrivacyControl) return false;
return true;
}
/**
* Identifies the authenticated user and fires a pageview on every route change.
* The provider fires the first pageview itself; this keeps subsequent SPA
@@ -33,9 +55,16 @@ function AnalyticsBridge() {
}
/**
* First-party product analytics for Hanzo Chat (@hanzo/event → cloud
* /v1/analytics). Mounted inside AuthContextProvider so it can read the live JWT
* and the resolved user; anonymous events still flow when no token is present.
* First-party telemetry for Hanzo Chat — the ONE @hanzo/event client to the ONE
* front door (POST api.hanzo.ai/v1/event), which Cloud fans out to the web
* (analytics), product (insights), and error (sentry) lenses. Mounted inside
* AuthContextProvider so it reads the live JWT and resolved user; a publishable
* ingest key carries logged-out / guest views. `<ObserveProvider>` rides the SAME
* client and adds default-on interaction autocapture ($click/$input/$change/
* $submit) with a semantic DOM hierarchy — input values redacted by default
* (PII-free). nav={false}: the event layer already counts pageviews exactly once
* (AnalyticsBridge), so observe does not also patch history; `enabled` mirrors the
* DNT/GPC consent gate.
*/
export default function AnalyticsProvider({ children }: { children: ReactNode }) {
const { token } = useAuthContext();
@@ -45,22 +74,28 @@ export default function AnalyticsProvider({ children }: { children: ReactNode })
const tokenRef = useRef<string | undefined>(token);
tokenRef.current = token;
const enabled = telemetryEnabled();
const config = useMemo(
() => ({
product: 'chat',
host: ANALYTICS_HOST,
ingestKey: INGEST_KEY,
enabled,
getToken: () => {
const value = tokenRef.current;
return value && value !== 'session' ? value : undefined;
},
}),
[],
[enabled],
);
return (
<CaptureProvider config={config}>
<AnalyticsBridge />
{children}
<ObserveProvider nav={false} enabled={enabled}>
<AnalyticsBridge />
{children}
</ObserveProvider>
</CaptureProvider>
);
}
+37 -5
View File
@@ -380,11 +380,17 @@ importers:
specifier: ^0.2.1
version: 0.2.1
'@hanzo/event':
specifier: ^0.2.0
version: 0.2.0(react@18.3.1)
specifier: ^0.3.1
version: 0.3.1(react@18.3.1)
'@hanzo/iam':
specifier: ^0.13.1
version: 0.13.1(react@18.3.1)
'@hanzo/logo':
specifier: ^1.0.13
version: 1.0.13(react@18.3.1)
'@hanzo/observe':
specifier: ^0.1.0
version: 0.1.0(react@18.3.1)
'@hanzo/ui':
specifier: npm:@hanzo/ui-shadcn@^5.7.4
version: '@hanzo/ui-shadcn@5.7.4(@hookform/resolvers@3.10.0(react-hook-form@7.71.2(react@18.3.1)))(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(@tanstack/react-query@4.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@3.6.0)(embla-carousel@8.6.0)(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lucide-react@0.394.0(react@18.3.1))(mermaid@11.13.0)(mobx@6.15.0)(next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.71.2(react@18.3.1))(react-resizable-panels@3.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(yaml@2.8.3))(validator@13.15.26)'
@@ -3794,8 +3800,8 @@ packages:
resolution: {integrity: sha512-m0k6V6Im8m5RXWHUy/E7DvpZ9tFBYUw21NTYFxNxUiPI1l+yEvHEVytqEbEtHZOCa9Ytb8nfuNzJIsfCeF/zZw==}
engines: {node: '>=18'}
'@hanzo/event@0.2.0':
resolution: {integrity: sha512-jx+xlT5no88PYg/k465/pQ7a+oQcfImB5X0dJ4/ZSSF5bwqPCItvN/9Znt4SQpq18EQeMuR5GiLmxo1wSP5uqw==}
'@hanzo/event@0.3.1':
resolution: {integrity: sha512-FAA6C/+hP8woUdle9VxRZXreCpCa+K1zlZAVOT8HTYHu6QH37LJIG2KS5NXutx80haSwNPGooJJlZRvx9x07ug==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
@@ -3811,6 +3817,22 @@ packages:
react:
optional: true
'@hanzo/logo@1.0.13':
resolution: {integrity: sha512-94VA6S7oVY5UZ816295Ez12p8a76ZGEct14JQwE6r5UCutD3K9TBgx5V01gjwxwVL9tWnXn/yut4PHRAY/9RNQ==}
peerDependencies:
react: '>=16.8.0'
peerDependenciesMeta:
react:
optional: true
'@hanzo/observe@0.1.0':
resolution: {integrity: sha512-VWfMz4F6gexchEaEjK9btxcTOVhWk8km64TN69hWLa1jWB4uTL9VdTj3cWZRDl/OP1lS8YcjpLdSI1q+C7fymg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react:
optional: true
'@hanzo/ui-shadcn@5.7.4':
resolution: {integrity: sha512-MFGPp+GYzaBUyd79F2mrk40SDLTFgUBEoPiSFVuJjekOVr5bJvUVNIWdoNtrl5ITL86N2ruiCNRhKGmEJyHFQA==}
hasBin: true
@@ -17693,7 +17715,7 @@ snapshots:
'@hanzo/ai@0.2.1': {}
'@hanzo/event@0.2.0(react@18.3.1)':
'@hanzo/event@0.3.1(react@18.3.1)':
optionalDependencies:
react: 18.3.1
@@ -17705,6 +17727,16 @@ snapshots:
optionalDependencies:
react: 18.3.1
'@hanzo/logo@1.0.13(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@hanzo/observe@0.1.0(react@18.3.1)':
dependencies:
'@hanzo/event': 0.3.1(react@18.3.1)
optionalDependencies:
react: 18.3.1
'@hanzo/ui-shadcn@5.7.4(@hookform/resolvers@3.10.0(react-hook-form@7.71.2(react@18.3.1)))(@modelcontextprotocol/sdk@1.22.0(@cfworker/json-schema@4.1.1))(@tanstack/react-query@4.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(date-fns@3.6.0)(embla-carousel@8.6.0)(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lucide-react@0.394.0(react@18.3.1))(mermaid@11.13.0)(mobx@6.15.0)(next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.71.2(react@18.3.1))(react-resizable-panels@3.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(tailwindcss@3.4.19(yaml@2.8.3))(validator@13.15.26)':
dependencies:
'@hookform/resolvers': 3.10.0(react-hook-form@7.71.2(react@18.3.1))