mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(analytics): standardize interaction tracking (#4873)
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { trackAnalyticsEvent } from '../../services/analytics';
|
||||
export { AnalyticsPageTracker } from './AnalyticsTracker';
|
||||
@@ -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')}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}` : '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user