Compare commits

...
Author SHA1 Message Date
Hanzo AI 05f8000996 chore: lock @hanzo/capture in pnpm-lock; map its exports subpath for tsc (moduleResolution node) 2026-07-16 10:13:06 -07:00
Hanzo DevandHanzo AI 211ddc42c2 feat(analytics): instrument chat with @hanzo/capture
Mount AnalyticsProvider inside AuthContextProvider so it reads the live JWT
and resolved user. Fire route-change pageviews, identify() on the stable user
id (never email), and capture CHAT_STARTED / CHAT_MESSAGE_SENT from the single
ask() funnel with model/endpoint identifiers only — never message content.

Emits to cloud /v1/analytics via the shared @hanzo/capture client.
2026-07-16 09:58:17 -07:00
7 changed files with 104 additions and 3 deletions
+1
View File
@@ -30,6 +30,7 @@
"homepage": "https://hanzo.ai/chat",
"dependencies": {
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5.7.4",
"@hanzo/capture": "^0.1.0",
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.17",
"@codesandbox/sandpack-react": "^2.19.10",
@@ -0,0 +1,66 @@
import { useEffect, useMemo, useRef } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import {
AnalyticsProvider as CaptureProvider,
useAnalytics,
usePageview,
} from '@hanzo/capture/react';
import { useAuthContext } from '~/hooks/AuthContext';
const ANALYTICS_HOST = import.meta.env.VITE_HANZO_ANALYTICS_HOST || 'https://api.hanzo.ai';
/**
* Identifies the authenticated user and fires a pageview on every route change.
* The provider fires the first pageview itself; this keeps subsequent SPA
* navigations tracked. Identity uses the stable user id — never email/PII.
*/
function AnalyticsBridge() {
const analytics = useAnalytics();
const { user, isAuthenticated } = useAuthContext();
const { pathname } = useLocation();
usePageview(pathname);
const userId = isAuthenticated ? user?.id : undefined;
useEffect(() => {
if (userId != null && userId) {
analytics.identify(userId);
}
}, [analytics, userId]);
return null;
}
/**
* First-party product analytics for Hanzo Chat (@hanzo/capture → 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.
*/
export default function AnalyticsProvider({ children }: { children: ReactNode }) {
const { token } = useAuthContext();
// Read the live token at capture time without re-creating the config: the
// 'session' sentinel (cookie-session auth, no JWT) is treated as anonymous.
const tokenRef = useRef<string | undefined>(token);
tokenRef.current = token;
const config = useMemo(
() => ({
product: 'chat',
host: ANALYTICS_HOST,
getToken: () => {
const value = tokenRef.current;
return value && value !== 'session' ? value : undefined;
},
}),
[],
);
return (
<CaptureProvider config={config}>
<AnalyticsBridge />
{children}
</CaptureProvider>
);
}
+1
View File
@@ -1,5 +1,6 @@
export { default as AssistantsProvider } from './AssistantsContext';
export { default as AgentsProvider } from './AgentsContext';
export { default as AnalyticsProvider } from './AnalyticsProvider';
export * from './ActivePanelContext';
export * from './AgentPanelContext';
export * from './ChatContext';
+13
View File
@@ -25,6 +25,8 @@ import type {
EndpointSchemaKey,
} from '@hanzochat/data-provider';
import type { SetterOrUpdater } from 'recoil';
import { useAnalytics } from '@hanzo/capture/react';
import { EVENTS } from '@hanzo/capture';
import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
@@ -65,6 +67,7 @@ export default function useChatFunctions({
}) {
const navigate = useNavigate();
const getSender = useGetSender();
const analytics = useAnalytics();
const { user } = useAuthContext();
const queryClient = useQueryClient();
const setFilesToDelete = useSetFilesToDelete();
@@ -125,6 +128,16 @@ export default function useChatFunctions({
const ephemeralAgent = getEphemeralAgent(conversationId ?? Constants.NEW_CONVO);
const isEditOrContinue = isEdited || isContinued;
// Product analytics: model/endpoint identifiers only — never message content.
if (!isRegenerate && !isContinued) {
const model = conversation?.model;
const isNewConvo = conversationId == null || conversationId === Constants.NEW_CONVO;
if (isNewConvo) {
analytics.capture(EVENTS.CHAT_STARTED, { endpoint, model });
}
analytics.capture(EVENTS.CHAT_MESSAGE_SENT, { endpoint, model });
}
let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
if (conversation?.promptPrefix) {
+5 -2
View File
@@ -13,6 +13,7 @@ import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
import AgentMarketplace from '~/components/Agents/Marketplace';
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext';
import { AnalyticsProvider } from '~/Providers';
import RouteErrorBoundary from './RouteErrorBoundary';
import BuildRoute from './BuildRoute';
import StartupLayout from './Layouts/Startup';
@@ -25,8 +26,10 @@ import Root from './Root';
const AuthLayout = () => (
<AuthContextProvider>
<Outlet />
<ApiErrorWatcher />
<AnalyticsProvider>
<Outlet />
<ApiErrorWatcher />
</AnalyticsProvider>
</AuthContextProvider>
);
+3 -1
View File
@@ -23,7 +23,9 @@
"~/*": ["./client/src/*"],
"test/*": ["./client/test/*"],
"*": ["./client/*", "../node_modules/*"],
"@hanzochat/data-provider/*": ["./packages/data-provider/*"]
"@hanzochat/data-provider/*": ["./packages/data-provider/*"],
"@hanzo/capture": ["./node_modules/@hanzo/capture/dist/index.d.ts"],
"@hanzo/capture/react": ["./node_modules/@hanzo/capture/dist/react.d.ts"]
}
},
"types": ["node", "jest", "@testing-library/jest-dom"],
+15
View File
@@ -376,6 +376,9 @@ importers:
'@dicebear/core':
specifier: ^9.2.2
version: 9.4.2
'@hanzo/capture':
specifier: ^0.1.0
version: 0.1.1(react@18.3.1)
'@hanzo/iam':
specifier: ^0.13.1
version: 0.13.1(react@18.3.1)
@@ -3784,6 +3787,14 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hanzo/capture@0.1.1':
resolution: {integrity: sha512-Sn5AVtEAzQ8+2cKw9ryMLeOTVzQygijzsYSTZIlF2KUZUPC82BAT5E4U/A04/PqfvupaH9oZpXI+I08ovAYOXA==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
react:
optional: true
'@hanzo/iam@0.13.1':
resolution: {integrity: sha512-MAqXY7jL5pcPkaUE0Qb/ePL2WKvk3p6EQDLHGZENj+/gbNKs7e/9qG+nmRlPMFVcwXPk+suzkBAEyIMT2EiJ8w==}
engines: {node: '>=18'}
@@ -17673,6 +17684,10 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hanzo/capture@0.1.1(react@18.3.1)':
optionalDependencies:
react: 18.3.1
'@hanzo/iam@0.13.1(react@18.3.1)':
dependencies:
jose: 6.2.2