feat(analytics): instrument chat with @hanzo/capture (#70)
Client-side product analytics via @hanzo/capture: pageviews, identify(user.id), and CHAT_STARTED/CHAT_MESSAGE_SENT with endpoint+model identifiers only — no message content or PII. Lockfile + tsconfig subpath mapping included.
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Generated
+15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user