feat(analytics): standardize interaction tracking (#4873)

This commit is contained in:
Steven Enamakel
2026-07-15 08:40:18 +03:00
committed by GitHub
parent e23be8c6eb
commit 2b6ff6b234
19 changed files with 412 additions and 190 deletions
+2
View File
@@ -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).
+2 -6
View File
@@ -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() {
<Router>
<CommandProvider>
<ServiceBlockingGate>
<AnalyticsPageTracker />
<AppShell />
<SecurityBanner />
{!onMobile && <DictationHotkeyManager />}
@@ -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
@@ -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 (
<>
<AnalyticsPageTracker />
<button type="button" onClick={() => navigate('/flows')}>
Navigate
</button>
</>
);
}
render(
<MemoryRouter initialEntries={['/chat']}>
<PageHarness />
</MemoryRouter>
);
expect(mocks.trackPageView).toHaveBeenLastCalledWith('/chat');
fireEvent.click(screen.getByRole('button', { name: 'Navigate' }));
expect(mocks.trackPageView).toHaveBeenLastCalledWith('/flows');
});
});
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { trackAnalyticsEvent } from '../../services/analytics';
export { AnalyticsPageTracker } from './AnalyticsTracker';
+1
View File
@@ -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')}
@@ -1173,6 +1173,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
<div className="flex flex-wrap items-center gap-2">
<Button
variant="primary"
analyticsId="skills-runner-run"
onClick={() => void handleRun()}
disabled={
run.status === 'submitting' || runBusy || missingRequired.length > 0
@@ -1335,6 +1336,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
<Button
variant="primary"
size="sm"
analyticsId="skills-runner-scheduled-run"
disabled={runBusy}
onClick={() => void handleRunJobNow(job)}>
{t('settings.skillsRunner.schedule.runNow')}
+7
View File
@@ -99,6 +99,13 @@ describe('Button', () => {
expect(btn.className).toMatch(/w-full/);
});
it('exposes a stable analytics identifier without forwarding the prop name', () => {
render(<Button analyticsId="flows-run">Run</Button>);
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(
+9 -1
View File
@@ -26,6 +26,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>((props, ref) => {
iconOnly = false,
leadingIcon,
trailingIcon,
analyticsId,
className,
type,
children,
@@ -96,7 +99,12 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
.join(' ');
return (
<button ref={ref} type={type ?? 'button'} className={classes} {...rest}>
<button
ref={ref}
type={type ?? 'button'}
className={classes}
data-analytics-id={analyticsId}
{...rest}>
{leadingIcon}
{children}
{trailingIcon}
@@ -5,6 +5,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { type ChatSendError, chatSendError } from '../../chat/chatSendError';
import { checkPromptInjection, promptGuardMessage } from '../../chat/promptInjectionGuard';
import { trackAnalyticsEvent } from '../../components/analytics';
import ApprovalRequestCard from '../../components/chat/ApprovalRequestCard';
import ArtifactCard from '../../components/chat/ArtifactCard';
import ChatComposer from '../../components/chat/ChatComposer';
@@ -76,7 +77,6 @@ import {
validateAndReadFile,
} from '../../lib/attachments';
import { useT } from '../../lib/i18n/I18nContext';
import { trackEvent } from '../../services/analytics';
import { applyOpenRouterFreeModels } from '../../services/api/openrouterFreeModels';
import { subagentApi } from '../../services/api/subagentApi';
import { threadApi } from '../../services/api/threadApi';
@@ -1192,7 +1192,10 @@ const Conversations = ({
profileId: selectedAgentProfileId,
locale: uiLocale,
});
trackEvent('chat_message_sent');
trackAnalyticsEvent('chat_message_sent', {
send_mode: 'standard',
has_attachments: pendingAttachments.length > 0,
});
// Backend accepted the send; lifecycle ('started' → 'streaming') now
// owns the `isSending` UI lock. Release the pending guard so the next
// user turn isn't blocked by a stale ref/state.
@@ -1292,7 +1295,10 @@ const Conversations = ({
if (requestId) {
dispatch(registerParallelRequest({ threadId, requestId }));
}
trackEvent('chat_parallel_message_sent');
trackAnalyticsEvent('chat_message_sent', {
send_mode: 'parallel',
has_attachments: pendingAttachments.length > 0,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSendError(chatSendError('cloud_send_failed', msg));
@@ -1371,7 +1377,10 @@ const Conversations = ({
setInputValue('');
setAttachments([]);
dispatch(enqueueFollowup({ threadId, message: followupMessage, label }));
trackEvent('chat_followup_queued');
trackAnalyticsEvent('chat_message_sent', {
send_mode: 'followup',
has_attachments: pendingAttachments.length > 0,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSendError(chatSendError('cloud_send_failed', msg));
+1
View File
@@ -595,6 +595,7 @@ function FlowEditor({
type="button"
variant="primary"
size="xs"
analyticsId="flow-canvas-run"
iconOnly
data-testid="flow-canvas-run"
aria-label={running ? t('flows.editor.running') : t('flows.editor.run')}
@@ -580,6 +580,19 @@ describe('trackPageView (OpenPanel)', () => {
expect(openPanelPayload().payload.properties.__timestamp).toEqual(expect.any(String));
});
test('records route templates without entity ids or query values', async () => {
window.location.hash = '#/chat/thread-private-123?tab=files';
const { initGA, trackPageView } = await freshAnalytics();
initGA();
trackPageView('/chat/thread-private-123');
expect(openPanelPayload().payload.properties).toMatchObject({
page: '/chat/:threadId',
page_hash: '#/chat/:threadId',
__path: '/chat/:threadId',
});
});
test('is a no-op when consent is off', async () => {
const { initGA, syncAnalyticsConsent, trackPageView } = await freshAnalytics();
initGA();
@@ -588,6 +601,22 @@ describe('trackPageView (OpenPanel)', () => {
expect(fetch).not.toHaveBeenCalled();
});
test('suppresses synchronous analytics provider failures', async () => {
hoisted.analyticsEnabled = true;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const { initGA, trackAnalyticsEvent } = await freshAnalytics();
initGA();
window.gtag = vi.fn(() => {
throw new Error('provider unavailable');
});
expect(() => trackAnalyticsEvent('app_open')).not.toThrow();
expect(warn).toHaveBeenCalledWith('[analytics] trackEvent failed', {
eventName: 'app_open',
error: 'Error',
});
});
test('is a no-op when OpenPanel was never initialized', async () => {
const { trackPageView } = await freshAnalytics();
trackPageView('/home');
@@ -601,6 +630,7 @@ describe('trackEvent (OpenPanel)', () => {
hoisted.appEnvironment = 'staging';
hoisted.currentUserId = null;
hoisted.isDev = false;
window.location.hash = '';
});
afterEach(() => {
@@ -726,6 +756,28 @@ describe('startUiInteractionTracking', () => {
stop();
});
test('distinguishes unlabelled controls without reading their text', async () => {
const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
const { initGA, startUiInteractionTracking } = await freshAnalytics();
initGA();
const stop = startUiInteractionTracking();
const first = document.createElement('button');
first.textContent = 'Private workspace name';
const second = document.createElement('button');
second.textContent = 'Another private value';
document.body.append(first, second);
second.click();
expect(openPanelPayload().payload.properties.control_id).toBe('button_2');
expect(JSON.stringify(openPanelPayload())).not.toContain('private');
expect(debug).toHaveBeenCalledWith('[analytics] controlIdentifier fallback', {
tag: 'button',
position: 1,
});
stop();
});
test('tracks checkbox/radio/select style control changes without raw values', async () => {
const { initGA, startUiInteractionTracking } = await freshAnalytics();
initGA();
+31 -179
View File
@@ -35,7 +35,8 @@ import {
SUPPORT_URL,
TAURI_CARGO_VERSION,
} from '../utils/config';
import { CoreRpcError } from './coreRpcClient';
import { startInteractionTracking } from './analyticsInteractions';
import { currentAppPath, currentPageHash, normalizeAnalyticsPagePath } from './analyticsRoutes';
// ---------------------------------------------------------------------------
// Google Analytics 4 typings — raw gtag.js API
@@ -55,10 +56,6 @@ declare global {
const OPENPANEL_TRACK_URL = `${OPENPANEL_API_URL}/track`;
const MAX_PENDING_ANALYTICS_EVENTS = 20;
const INTERACTIVE_CLICK_SELECTOR =
'button,a,[role="button"],summary,[data-track],[data-analytics-id],[data-testid],[data-walkthrough]';
const CONTROL_CHANGE_SELECTOR =
'select,input[type="checkbox"],input[type="radio"],input[type="range"],[role="switch"],[role="checkbox"],[role="radio"]';
// ---------------------------------------------------------------------------
// Module-level state
@@ -67,9 +64,8 @@ const CONTROL_CHANGE_SELECTOR =
let gaInitialized = false;
let opInitialized = false;
let analyticsConsentSynced = false;
let uiInteractionTrackingStarted = false;
type AnalyticsParams = Record<string, string | number | boolean>;
export type AnalyticsParams = Record<string, string | number | boolean>;
interface PendingAnalyticsEvent {
type: 'event' | 'page_view';
@@ -93,7 +89,7 @@ let analyticsEnabled = false;
* Any `trackEvent` call with a name not in this set is dropped and a warning
* is logged.
*/
export const ALLOWED_EVENTS = new Set([
const ALLOWED_EVENT_NAMES = [
'app_open',
'onboarding_start',
'onboarding_step_complete',
@@ -101,6 +97,9 @@ export const ALLOWED_EVENTS = new Set([
'account_connect_start',
'account_connect_success',
'chat_message_sent',
'automation_run_started',
'automation_run_resumed',
'automation_run_cancelled',
'skill_install',
'skill_uninstall',
'tab_bar_change',
@@ -108,7 +107,10 @@ export const ALLOWED_EVENTS = new Set([
'ui_click',
'ui_control_change',
'ui_form_submit',
]);
] as const;
export type AnalyticsEventName = (typeof ALLOWED_EVENT_NAMES)[number];
export const ALLOWED_EVENTS: ReadonlySet<string> = new Set(ALLOWED_EVENT_NAMES);
/** Check if the current user has opted into analytics. */
export function isAnalyticsEnabled(): boolean {
@@ -117,13 +119,12 @@ export function isAnalyticsEnabled(): boolean {
/**
* Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`.
* `instanceof` can fail across module scopes (test harness, dynamic import,
* Vitest module isolation), so also accept a duck-typed match on `name`
* and `kind`. Used by the Sentry `beforeSend` filter to drop the
* Use a duck-typed match on `name` and `kind` so this service stays independent
* from the RPC client and works across test/module realms. Used by the Sentry
* `beforeSend` filter to drop the
* OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source.
*/
function isCoreRpcTimeoutError(err: unknown): boolean {
if (err instanceof CoreRpcError) return err.kind === 'timeout';
if (typeof err !== 'object' || err === null) return false;
const candidate = err as { name?: unknown; kind?: unknown };
return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout';
@@ -389,6 +390,22 @@ export function trackPageView(path: string): void {
* are dropped and a console warning is emitted.
*/
export function trackEvent(eventName: string, params?: AnalyticsParams): void {
try {
trackEventUnsafe(eventName, params);
} catch (error) {
console.warn('[analytics] trackEvent failed', {
eventName,
error: error instanceof Error ? error.name : typeof error,
});
}
}
/** Typed, best-effort facade for successful domain outcomes. */
export function trackAnalyticsEvent(eventName: AnalyticsEventName, params?: AnalyticsParams): void {
trackEvent(eventName, params);
}
function trackEventUnsafe(eventName: string, params?: AnalyticsParams): void {
if (!ALLOWED_EVENTS.has(eventName)) {
console.warn(
`[analytics] trackEvent dropped — '${eventName}' is not in ALLOWED_EVENTS allowlist`
@@ -413,56 +430,7 @@ export function trackEvent(eventName: string, params?: AnalyticsParams): void {
}
export function startUiInteractionTracking(): () => void {
if (uiInteractionTrackingStarted || typeof document === 'undefined') return () => undefined;
uiInteractionTrackingStarted = true;
const handleClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null;
const element = target?.closest(INTERACTIVE_CLICK_SELECTOR);
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
trackEvent('ui_click', {
...interactionBaseProperties(element),
interaction_kind: 'click',
control_id: controlIdentifier(element),
destination: destinationForElement(element),
});
};
const handleChange = (event: Event) => {
const target = event.target instanceof Element ? event.target : null;
const element = target?.closest(CONTROL_CHANGE_SELECTOR);
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
trackEvent('ui_control_change', {
...interactionBaseProperties(element),
interaction_kind: 'change',
control_id: controlIdentifier(element),
control_state: controlState(element),
});
};
const handleSubmit = (event: SubmitEvent) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!form || shouldSkipInteractionElement(form)) return;
trackEvent('ui_form_submit', {
...interactionBaseProperties(form),
interaction_kind: 'submit',
control_id: controlIdentifier(form),
});
};
document.addEventListener('click', handleClick, true);
document.addEventListener('change', handleChange, true);
document.addEventListener('submit', handleSubmit, true);
return () => {
document.removeEventListener('click', handleClick, true);
document.removeEventListener('change', handleChange, true);
document.removeEventListener('submit', handleSubmit, true);
uiInteractionTrackingStarted = false;
};
return startInteractionTracking(trackEvent);
}
function queuePendingAnalyticsEvent(event: PendingAnalyticsEvent): void {
@@ -485,122 +453,6 @@ function flushPendingAnalyticsEvents(): void {
}
}
function interactionBaseProperties(element: HTMLElement): AnalyticsParams {
return {
page: currentAppPath(),
page_hash: currentPageHash(),
element_tag: element.tagName.toLowerCase(),
element_role: scrubIdentifier(element.getAttribute('role')) ?? '',
element_type: scrubIdentifier(element.getAttribute('type')) ?? '',
};
}
function controlIdentifier(element: HTMLElement): string {
const explicit =
element.getAttribute('data-analytics-id') ??
element.getAttribute('data-track') ??
element.getAttribute('data-testid') ??
element.getAttribute('data-walkthrough') ??
element.getAttribute('name') ??
element.id;
const scrubbed = scrubIdentifier(explicit);
if (scrubbed) return scrubbed;
const hrefDestination = destinationForElement(element);
if (hrefDestination) return `link_${scrubIdentifier(hrefDestination) ?? 'internal'}`;
const container = nearestStableContainer(element);
const tag = element.tagName.toLowerCase();
if (container) return `${tag}_in_${container}`;
return tag;
}
function destinationForElement(element: HTMLElement): string {
const href = element instanceof HTMLAnchorElement ? element.getAttribute('href') : null;
if (!href) return '';
if (href.startsWith('#/')) return href.slice(1);
if (href.startsWith('/')) return href;
return href.startsWith('http') ? 'external' : '';
}
function controlState(element: HTMLElement): string {
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
return element.checked ? 'checked' : 'unchecked';
}
if (element.type === 'range') return 'changed';
}
if (element instanceof HTMLSelectElement) return 'selected';
const ariaChecked = element.getAttribute('aria-checked');
if (ariaChecked === 'true' || ariaChecked === 'false' || ariaChecked === 'mixed') {
return ariaChecked;
}
return 'changed';
}
function nearestStableContainer(element: HTMLElement): string | undefined {
const container = element.closest('[data-testid],[data-walkthrough],[data-analytics-id]');
if (!(container instanceof HTMLElement) || container === element) return undefined;
return scrubIdentifier(
container.getAttribute('data-analytics-id') ??
container.getAttribute('data-testid') ??
container.getAttribute('data-walkthrough')
);
}
function shouldSkipInteractionElement(element: HTMLElement): boolean {
if (element.closest('[data-analytics-skip="true"],[data-no-analytics="true"]')) return true;
if (element.closest('[contenteditable="true"]')) return true;
if (element instanceof HTMLInputElement) {
return ['text', 'search', 'email', 'password', 'tel', 'url', 'number', 'file'].includes(
element.type
);
}
if (element instanceof HTMLTextAreaElement) return true;
return false;
}
function scrubIdentifier(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const withoutQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
const scrubbed = withoutQuery
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ':email')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
.replace(/\b[0-9a-f]{16,}\b/gi, ':id')
.replace(/\b\d{3,}\b/g, ':num')
.replace(/[^a-zA-Z0-9:_/-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase()
.slice(0, 80);
return scrubbed || undefined;
}
function currentAppPath(): string {
if (typeof window === 'undefined') return '';
return normalizeAnalyticsPagePath(window.location.pathname);
}
function currentPageHash(): string {
if (typeof window === 'undefined') return '';
return window.location.hash.startsWith('#/') ? window.location.hash : '';
}
function normalizeAnalyticsPagePath(path: string): string {
if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) {
return hashToPath(window.location.hash);
}
if (path.startsWith('#/')) return hashToPath(path);
return path || '/';
}
function hashToPath(hash: string): string {
const withoutHash = hash.slice(1);
return withoutHash || '/';
}
async function sendOpenPanelTrack(eventName: string, params?: AnalyticsParams): Promise<void> {
const profileId = currentAnalyticsUserId();
const properties = {
+168
View File
@@ -0,0 +1,168 @@
import type { AnalyticsParams } from './analytics';
import { currentAppPath, currentPageHash } from './analyticsRoutes';
const INTERACTIVE_CLICK_SELECTOR =
'button,a,[role="button"],summary,[data-track],[data-analytics-id],[data-testid],[data-walkthrough]';
const CONTROL_CHANGE_SELECTOR =
'select,input[type="checkbox"],input[type="radio"],input[type="range"],[role="switch"],[role="checkbox"],[role="radio"]';
type InteractionEventName = 'ui_click' | 'ui_control_change' | 'ui_form_submit';
type InteractionTracker = (eventName: InteractionEventName, params: AnalyticsParams) => void;
let trackingStarted = false;
/** Install app-wide delegated listeners for privacy-safe UI interaction events. */
export function startInteractionTracking(track: InteractionTracker): () => void {
if (trackingStarted || typeof document === 'undefined') return () => undefined;
trackingStarted = true;
const handleClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null;
const element = target?.closest(INTERACTIVE_CLICK_SELECTOR);
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
track('ui_click', {
...interactionBaseProperties(element),
interaction_kind: 'click',
control_id: controlIdentifier(element),
destination: destinationForElement(element),
});
};
const handleChange = (event: Event) => {
const target = event.target instanceof Element ? event.target : null;
const element = target?.closest(CONTROL_CHANGE_SELECTOR);
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
track('ui_control_change', {
...interactionBaseProperties(element),
interaction_kind: 'change',
control_id: controlIdentifier(element),
control_state: controlState(element),
});
};
const handleSubmit = (event: SubmitEvent) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!form || shouldSkipInteractionElement(form)) return;
track('ui_form_submit', {
...interactionBaseProperties(form),
interaction_kind: 'submit',
control_id: controlIdentifier(form),
});
};
document.addEventListener('click', handleClick, true);
document.addEventListener('change', handleChange, true);
document.addEventListener('submit', handleSubmit, true);
return () => {
document.removeEventListener('click', handleClick, true);
document.removeEventListener('change', handleChange, true);
document.removeEventListener('submit', handleSubmit, true);
trackingStarted = false;
};
}
function interactionBaseProperties(element: HTMLElement): AnalyticsParams {
return {
page: currentAppPath(),
page_hash: currentPageHash(),
element_tag: element.tagName.toLowerCase(),
element_role: scrubIdentifier(element.getAttribute('role')) ?? '',
element_type: scrubIdentifier(element.getAttribute('type')) ?? '',
};
}
function controlIdentifier(element: HTMLElement): string {
const explicit =
element.getAttribute('data-analytics-id') ??
element.getAttribute('data-track') ??
element.getAttribute('data-testid') ??
element.getAttribute('data-walkthrough') ??
element.getAttribute('name') ??
element.id;
const scrubbed = scrubIdentifier(explicit);
if (scrubbed) return scrubbed;
const hrefDestination = destinationForElement(element);
if (hrefDestination) return `link_${scrubIdentifier(hrefDestination) ?? 'internal'}`;
const container = nearestStableContainer(element);
const tag = element.tagName.toLowerCase();
if (container) return `${tag}_in_${container}`;
// Every native/shared button is still captured even when its call site has
// not yet received a semantic data-analytics-id. Keep those controls
// distinguishable without reading their text/aria-label (both can contain
// user content) by assigning a page-local, DOM-order fallback.
const peers = Array.from(document.querySelectorAll(INTERACTIVE_CLICK_SELECTOR)).filter(
peer => peer instanceof HTMLElement && !shouldSkipInteractionElement(peer)
);
const position = peers.indexOf(element);
console.debug('[analytics] controlIdentifier fallback', { tag, position });
return position >= 0 ? `${tag}_${position + 1}` : tag;
}
function destinationForElement(element: HTMLElement): string {
const href = element instanceof HTMLAnchorElement ? element.getAttribute('href') : null;
if (!href) return '';
if (href.startsWith('#/')) return href.slice(1);
if (href.startsWith('/')) return href;
return href.startsWith('http') ? 'external' : '';
}
function controlState(element: HTMLElement): string {
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
return element.checked ? 'checked' : 'unchecked';
}
if (element.type === 'range') return 'changed';
}
if (element instanceof HTMLSelectElement) return 'selected';
const ariaChecked = element.getAttribute('aria-checked');
if (ariaChecked === 'true' || ariaChecked === 'false' || ariaChecked === 'mixed') {
return ariaChecked;
}
return 'changed';
}
function nearestStableContainer(element: HTMLElement): string | undefined {
const container = element.closest('[data-testid],[data-walkthrough],[data-analytics-id]');
if (!(container instanceof HTMLElement) || container === element) return undefined;
return scrubIdentifier(
container.getAttribute('data-analytics-id') ??
container.getAttribute('data-testid') ??
container.getAttribute('data-walkthrough')
);
}
function shouldSkipInteractionElement(element: HTMLElement): boolean {
if (element.closest('[data-analytics-skip="true"],[data-no-analytics="true"]')) return true;
if (element.closest('[contenteditable="true"]')) return true;
if (element instanceof HTMLInputElement) {
return ['text', 'search', 'email', 'password', 'tel', 'url', 'number', 'file'].includes(
element.type
);
}
if (element instanceof HTMLTextAreaElement) return true;
return false;
}
function scrubIdentifier(value: string | null | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const withoutQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
const scrubbed = withoutQuery
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ':email')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
.replace(/\b[0-9a-f]{16,}\b/gi, ':id')
.replace(/\b\d{3,}\b/g, ':num')
.replace(/[^a-zA-Z0-9:_/-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase()
.slice(0, 80);
return scrubbed || undefined;
}
+54
View File
@@ -0,0 +1,54 @@
/** Convert a HashRouter hash into its application path. */
export function hashToPath(hash: string): string {
const withoutHash = hash.slice(1);
return withoutHash || '/';
}
/** Normalize dynamic application paths into privacy-safe analytics templates. */
export function normalizeAnalyticsPagePath(path: string): string {
const rawPath =
typeof window !== 'undefined' && window.location.hash.startsWith('#/')
? hashToPath(window.location.hash)
: path.startsWith('#/')
? hashToPath(path)
: path || '/';
// Analytics records route templates, never entity identifiers or query/hash
// values. Besides avoiding high-cardinality dashboards, this prevents thread,
// flow, profile, and team identifiers from leaving the app.
const pathname = rawPath.split(/[?#]/, 1)[0] || '/';
if (/^\/chat\/[^/]+/.test(pathname)) return '/chat/:threadId';
if (/^\/flows\/[^/]+/.test(pathname) && pathname !== '/flows/draft') {
return '/flows/:flowId';
}
if (/^\/settings\/team\/manage\/[^/]+\/members$/.test(pathname)) {
return '/settings/team/manage/:teamId/members';
}
if (/^\/settings\/team\/manage\/[^/]+\/invites$/.test(pathname)) {
return '/settings/team/manage/:teamId/invites';
}
if (/^\/settings\/team\/manage\/[^/]+$/.test(pathname)) {
return '/settings/team/manage/:teamId';
}
if (/^\/settings\/agents\/edit\/[^/]+$/.test(pathname)) {
return '/settings/agents/edit/:id';
}
if (/^\/settings\/profiles\/edit\/[^/]+$/.test(pathname)) {
return '/settings/profiles/edit/:id';
}
if (/^\/callback\/[^/]+\/[^/]+$/.test(pathname)) return '/callback/:kind/:status';
if (/^\/callback\/[^/]+$/.test(pathname)) return '/callback/:kind';
return pathname;
}
/** Read the current privacy-normalized application path. */
export function currentAppPath(): string {
if (typeof window === 'undefined') return '';
return normalizeAnalyticsPagePath(window.location.pathname);
}
/** Return the canonical HashRouter value without exposing entity identifiers. */
export function currentPageHash(): string {
if (typeof window === 'undefined') return '';
return window.location.hash.startsWith('#/') ? `#${currentAppPath()}` : '';
}
+3
View File
@@ -30,6 +30,7 @@ import debug from 'debug';
import type { WorkflowGraph } from '../../lib/flows/types';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import { trackAnalyticsEvent } from '../analytics';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('flowsApi');
@@ -390,6 +391,7 @@ export async function resumeFlow(
result.thread_id,
result.pending_approvals?.length ?? 0
);
trackAnalyticsEvent('automation_run_resumed', { automation_kind: 'flow' });
return result;
}
@@ -509,6 +511,7 @@ export async function runFlow(id: string, input?: unknown): Promise<FlowResumeRe
result.thread_id,
result.pending_approvals?.length ?? 0
);
trackAnalyticsEvent('automation_run_started', { automation_kind: 'flow' });
return result;
}
+5
View File
@@ -1,5 +1,6 @@
import debug from 'debug';
import { trackAnalyticsEvent } from '../analytics';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('skillsApi');
@@ -478,6 +479,7 @@ export const skillsApi = {
log: raw.log,
};
log('runWorkflow: response runId=%s log=%s', normalized.run_id, normalized.log);
trackAnalyticsEvent('automation_run_started', { automation_kind: 'skill' });
return normalized;
},
/**
@@ -493,6 +495,9 @@ export const skillsApi = {
});
const raw = unwrapEnvelope(response);
log('cancelRun: response cancelled=%s', raw.cancelled);
if (raw.cancelled) {
trackAnalyticsEvent('automation_run_cancelled', { automation_kind: 'skill' });
}
return raw.cancelled;
},
+6
View File
@@ -18,6 +18,7 @@
*/
import debug from 'debug';
import { trackAnalyticsEvent } from '../analytics';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('workflowRunsApi');
@@ -233,6 +234,7 @@ export const workflowRunsApi = {
log('startRun: request definitionId=%s', params.definitionId);
const result = await callCoreRpc<RunResult>({ method: 'openhuman.workflow_run_start', params });
log('startRun: id=%s status=%s', result.workflowRun.id, result.workflowRun.status);
trackAnalyticsEvent('automation_run_started', { automation_kind: 'orchestration' });
return result.workflowRun;
},
@@ -244,6 +246,9 @@ export const workflowRunsApi = {
params: { id },
});
log('stopRun: status=%s', result?.workflowRun?.status ?? 'null');
if (result?.workflowRun) {
trackAnalyticsEvent('automation_run_cancelled', { automation_kind: 'orchestration' });
}
return result?.workflowRun ?? null;
},
@@ -255,6 +260,7 @@ export const workflowRunsApi = {
params: { id },
});
log('resumeRun: status=%s', result.workflowRun.status);
trackAnalyticsEvent('automation_run_resumed', { automation_kind: 'orchestration' });
return result.workflowRun;
},
};
+6
View File
@@ -116,6 +116,12 @@ describe('Workflows create → run → inspect (real UI flow)', () => {
// for a persisted, non-draft flow).
await waitForTestId('flow-canvas-page', 15_000);
await waitForTestId('flow-canvas-run', 15_000);
// Shared buttons expose analytics through the typed Button contract.
// Assert the stable, content-free identifier reaches the real DOM rather
// than leaking the generated flow id into the analytics dimension.
const runButton = await waitForTestId('flow-canvas-run', 15_000);
expect(await runButton.getAttribute('data-analytics-id')).toBe('flow-canvas-run');
});
it('runs the flow from the canvas without error', async function () {