From 2b6ff6b2344d2ea14e0ae17e08767ecce872064b Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:40:18 +0300 Subject: [PATCH] feat(analytics): standardize interaction tracking (#4873) --- AGENTS.md | 2 + app/src/App.tsx | 8 +- .../analytics/AnalyticsTracker.test.tsx | 35 +++ .../components/analytics/AnalyticsTracker.tsx | 13 ++ app/src/components/analytics/index.ts | 2 + app/src/components/flows/FlowListRow.tsx | 1 + .../components/skills/WorkflowRunnerBody.tsx | 2 + app/src/components/ui/Button.test.tsx | 7 + app/src/components/ui/Button.tsx | 10 +- .../features/conversations/Conversations.tsx | 17 +- app/src/pages/FlowCanvasPage.tsx | 1 + app/src/services/__tests__/analytics.test.ts | 52 +++++ app/src/services/analytics.ts | 210 +++--------------- app/src/services/analyticsInteractions.ts | 168 ++++++++++++++ app/src/services/analyticsRoutes.ts | 54 +++++ app/src/services/api/flowsApi.ts | 3 + app/src/services/api/skillsApi.ts | 5 + app/src/services/api/workflowRunsApi.ts | 6 + app/test/e2e/specs/flows.spec.ts | 6 + 19 files changed, 412 insertions(+), 190 deletions(-) create mode 100644 app/src/components/analytics/AnalyticsTracker.test.tsx create mode 100644 app/src/components/analytics/AnalyticsTracker.tsx create mode 100644 app/src/components/analytics/index.ts create mode 100644 app/src/services/analyticsInteractions.ts create mode 100644 app/src/services/analyticsRoutes.ts diff --git a/AGENTS.md b/AGENTS.md index 3d15a9ed1..03ecc7e77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,8 @@ No `UserProvider`/`AIProvider`/`SkillProvider` — auth lives in `CoreStateProvi **Services** (`services/`): `apiClient`, `socketService`, `coreRpcClient`, `coreCommandClient`, `chatService`, `analytics`, `notificationService`, `webviewAccountService`, `daemonHealthService`, plus domain `api/*` clients. Always use `coreRpcClient` (which invokes the `relay_http_rpc` Tauri command) for core RPC. +**Analytics**: use `Button analyticsId="stable-content-free-id"` for shared button interactions, `AnalyticsPageTracker` once inside the router, and `trackAnalyticsEvent` from `components/analytics` for successful domain outcomes (messages, automation runs, connections, etc.). Native controls and links may use `data-analytics-id` directly. Use privacy-safe dimensions only; never send user-authored text, entity IDs, filenames, credentials, or error messages. `services/analytics.ts` is the consent/provider implementation, not the feature-code API. + **Routing** (`AppRoutes.tsx`, HashRouter): `/` (Welcome), `/auth`, `/onboarding/*`, `/chat/:threadId?`, `/human`, `/brain` (+ `/brain/tinyplace-orchestration`), `/orchestration`, `/connections`, `/flows` (+ `/flows/:id`, `/flows/draft`), `/agent-world/*`, `/invites`, `/notifications`, `/rewards`, `/settings/*`, `/feedback`. Back-compat redirects: `/home`→`/chat`, `/skills`→`/connections`, `/channels`→`/connections?tab=messaging`, `/intelligence` & `/activity`→`/settings/notifications`, `/routines` & `/workflows`→`/settings/automations`, `/webhooks`→`/settings/integrations#webhooks`. No `/login`, `/mnemonic`, `/agents`, `/conversations`. **AI config**: bundled prompts in `src/openhuman/agent/prompts/` ship via `tauri.conf.json` resources and are read core-side (`app/src/lib/ai/` holds agent-context helpers, not prompt loaders). diff --git a/app/src/App.tsx b/app/src/App.tsx index 6e2f9737b..09969b5df 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; import WebviewHost from './components/accounts/WebviewHost'; +import { AnalyticsPageTracker } from './components/analytics'; import AnnouncementGate from './components/Announcement/AnnouncementGate'; import AppBackground from './components/AppBackground'; import AppUpdatePrompt from './components/AppUpdatePrompt'; @@ -51,7 +52,6 @@ import ChatRuntimeProvider from './providers/ChatRuntimeProvider'; import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import ThemeProvider from './providers/ThemeProvider'; -import { trackPageView } from './services/analytics'; import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor'; import { startInternetStatusListener, @@ -149,6 +149,7 @@ function App() { + {!onMobile && } @@ -231,11 +232,6 @@ export function AppShellDesktop() { navigate, ]); - // Track route changes as anonymous page views. - useEffect(() => { - trackPageView(location.pathname); - }, [location.pathname]); - // Hide the active connected-app webview when we navigate away from the chat // surface. Provider CEF selection is intentionally route-independent; any // real route change clears that high-level selection so the native view diff --git a/app/src/components/analytics/AnalyticsTracker.test.tsx b/app/src/components/analytics/AnalyticsTracker.test.tsx new file mode 100644 index 000000000..241c11d88 --- /dev/null +++ b/app/src/components/analytics/AnalyticsTracker.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; + +import { AnalyticsPageTracker } from './AnalyticsTracker'; + +const mocks = vi.hoisted(() => ({ trackPageView: vi.fn() })); + +vi.mock('../../services/analytics', () => ({ trackPageView: mocks.trackPageView })); + +describe('analytics tracking primitives', () => { + it('tracks a page when its path changes', () => { + function PageHarness() { + const navigate = useNavigate(); + return ( + <> + + + + ); + } + + render( + + + + ); + expect(mocks.trackPageView).toHaveBeenLastCalledWith('/chat'); + + fireEvent.click(screen.getByRole('button', { name: 'Navigate' })); + expect(mocks.trackPageView).toHaveBeenLastCalledWith('/flows'); + }); +}); diff --git a/app/src/components/analytics/AnalyticsTracker.tsx b/app/src/components/analytics/AnalyticsTracker.tsx new file mode 100644 index 000000000..cccbde37b --- /dev/null +++ b/app/src/components/analytics/AnalyticsTracker.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { trackPageView } from '../../services/analytics'; + +/** Standard route-view tracker. Mount once inside the active router. */ +export function AnalyticsPageTracker() { + const { pathname } = useLocation(); + useEffect(() => { + trackPageView(pathname); + }, [pathname]); + return null; +} diff --git a/app/src/components/analytics/index.ts b/app/src/components/analytics/index.ts new file mode 100644 index 000000000..18697d12a --- /dev/null +++ b/app/src/components/analytics/index.ts @@ -0,0 +1,2 @@ +export { trackAnalyticsEvent } from '../../services/analytics'; +export { AnalyticsPageTracker } from './AnalyticsTracker'; diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index 1e549a378..ea063788a 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -178,6 +178,7 @@ const FlowListRow = ({ type="button" variant="primary" size="sm" + analyticsId="flows-list-run" iconOnly data-testid={`flow-run-${flow.id}`} aria-label={runBusy ? t('flows.list.running') : t('flows.list.runNow')} diff --git a/app/src/components/skills/WorkflowRunnerBody.tsx b/app/src/components/skills/WorkflowRunnerBody.tsx index 342b29624..afda61654 100644 --- a/app/src/components/skills/WorkflowRunnerBody.tsx +++ b/app/src/components/skills/WorkflowRunnerBody.tsx @@ -1173,6 +1173,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
); + const btn = screen.getByRole('button', { name: 'Run' }); + expect(btn).toHaveAttribute('data-analytics-id', 'flows-run'); + expect(btn).not.toHaveAttribute('analyticsId'); + }); + it('respects disabled: does not fire onClick and has disabled attr', () => { const onClick = vi.fn(); render( diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index 5c56145e9..9972f7524 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -26,6 +26,8 @@ export interface ButtonProps extends ButtonHTMLAttributes { iconOnly?: boolean; leadingIcon?: ReactNode; trailingIcon?: ReactNode; + /** Stable, content-free identifier consumed by the app-wide analytics tracker. */ + analyticsId?: string; } const BASE = @@ -84,6 +86,7 @@ const Button = forwardRef((props, ref) => { iconOnly = false, leadingIcon, trailingIcon, + analyticsId, className, type, children, @@ -96,7 +99,12 @@ const Button = forwardRef((props, ref) => { .join(' '); return ( -