From 68a5792b15346df12f321e31e4a3204bcfb58f8c Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Tue, 19 May 2026 23:56:04 +0800 Subject: [PATCH 01/10] feat(analytics): ship PostHog v2 event schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the PostHog wire format with the product team's v2 tracking spec (Open Design 埋点文档 2.0). The previous v1 catalogue defined a flat per-page event name (home_view / studio_click / settings_view…); v2 collapses everything to four core events identified through the page_name + area + element triplet so dashboards can group by surface without owning a separate event per page. Key changes - packages/contracts/src/analytics: collapse to page_view / ui_click / surface_view / *_result event names; bump EVENT_SCHEMA_VERSION to 2; rename the wire field anonymous_id → device_id (value unchanged); promote the configure-state triplet (has_available_configure_cli / configure_type / configure_availability) to a global PostHog register so every event inherits it without per-helper boilerplate. - apps/web/src/analytics: rewrite the 43 trackXxx helpers behind the new typed catalogue; opt out of PostHog's built-in UA bot filter so legitimate embedded webviews, fingerprinted browsers, and the Playwright-based e2e runs ingest captures (the Privacy → "Share usage data" toggle remains the single consent gate). - apps/web components: wire P0/P1/P2 click + view + result surfaces end-to-end — left nav, toolbar, home chat composer, recent projects, new project modal, plugins / design systems / integrations / automations pages, file manager, artifact toolbar/header/share popup, feedback panel, settings sidebar / language / appearance / notifications / pets / privacy / connectors. Fixes the v1 feedback bug where action=clear_feedback_rating shipped rating=null instead of the rating being cleared. - apps/daemon: extend run_created / run_finished with the v2 context (entry_from / project_kind / target_platforms / fidelity / connectors / etc.), add explicit error_code classification on result=failed (run.errorCode → AGENT_SIGNAL_* → AGENT_EXIT_* → AGENT_TERMINATED_UNKNOWN), and read device_id from the new x-od-analytics-device-id header. Also moves the run_created / run_finished emission to the canonical /api/runs handler in server.ts; the chat-routes copy was shadowed by Express's earlier registration and never executed, which also meant run.clientType never made it to Langfuse — fixed in the same move. Verification - pnpm guard / pnpm typecheck clean for daemon, web, and contracts. - pnpm --filter @open-design/web test: 1645/1645 passing. - End-to-end smoke through Playwright + local PostHog ingest project 420348: every page_view (home/projects/automations/design_systems/ plugins/integrations/chat_panel/file_manager), every nav element, the new_project_modal surface_view + tab + create flow, the plugin_replacement_modal surface_view, settings_view across nine sections, settings_cli_test_result (codex CLI), the project_create_result success path, and run_created + run_finished (result=failed, error_code=AGENT_EXIT_1) all reached PostHog with the v2 schema and the expected device_id / page_name / area / element / fidelity / target_platforms props. The remaining *_result events (artifact_export / feedback_submit / file_upload / plugin_replacement / settings_byok_test / settings_connector_auth) are wired in code; production traffic will trigger them. --- apps/daemon/src/analytics.ts | 21 +- apps/daemon/src/chat-routes.ts | 178 +-- apps/daemon/src/server.ts | 149 ++ apps/web/src/App.tsx | 39 +- apps/web/src/analytics/client.ts | 61 +- apps/web/src/analytics/events.ts | 645 ++++++-- apps/web/src/analytics/provider.tsx | 4 +- apps/web/src/components/AssistantMessage.tsx | 136 +- apps/web/src/components/ChatComposer.tsx | 64 +- apps/web/src/components/ChatPane.tsx | 54 +- apps/web/src/components/ConnectorsBrowser.tsx | 92 +- apps/web/src/components/DesignFilesPanel.tsx | 12 +- .../components/DesignSystemPreviewModal.tsx | 78 +- apps/web/src/components/DesignSystemsTab.tsx | 61 +- apps/web/src/components/DesignsTab.tsx | 126 +- apps/web/src/components/EntryHelpMenu.tsx | 75 +- apps/web/src/components/EntryShell.tsx | 79 +- apps/web/src/components/FileViewer.tsx | 158 +- apps/web/src/components/FileWorkspace.tsx | 114 +- apps/web/src/components/HomeView.tsx | 146 +- apps/web/src/components/IntegrationsView.tsx | 63 +- apps/web/src/components/McpClientSection.tsx | 21 +- apps/web/src/components/NewProjectPanel.tsx | 61 +- apps/web/src/components/PluginsView.tsx | 204 ++- apps/web/src/components/PreviewModal.tsx | 49 +- .../src/components/PrivacyConsentModal.tsx | 20 +- apps/web/src/components/PrivacySection.tsx | 42 +- apps/web/src/components/ProjectView.tsx | 1 + apps/web/src/components/SettingsDialog.tsx | 330 +++- apps/web/src/components/TasksView.tsx | 126 +- .../web/src/components/UseEverywhereModal.tsx | 49 +- apps/web/src/components/pet/PetSettings.tsx | 49 +- packages/contracts/src/analytics/events.ts | 1373 +++++++++++++---- .../contracts/src/analytics/public-params.ts | 39 +- 34 files changed, 3801 insertions(+), 918 deletions(-) diff --git a/apps/daemon/src/analytics.ts b/apps/daemon/src/analytics.ts index 084f530a50..21df3cacab 100644 --- a/apps/daemon/src/analytics.ts +++ b/apps/daemon/src/analytics.ts @@ -5,14 +5,14 @@ // Web-side captures (apps/web/src/analytics) carry the matching identity in // HTTP headers (see x-od-analytics-* constants in @open-design/contracts); // daemon reads those headers off the request and reuses the same -// anonymous_id as the PostHog distinct_id so events from both sides land on -// the same person. +// device_id as the PostHog distinct_id so events from both sides land on +// the same person. (v2: renamed from `anonymous_id`.) import crypto from 'node:crypto'; import { PostHog } from 'posthog-node'; import type { Request } from 'express'; import { - ANALYTICS_HEADER_ANONYMOUS_ID, + ANALYTICS_HEADER_DEVICE_ID, ANALYTICS_HEADER_CLIENT_TYPE, ANALYTICS_HEADER_LOCALE, ANALYTICS_HEADER_REQUEST_ID, @@ -27,7 +27,7 @@ import { readAppConfig } from './app-config.js'; const DEFAULT_HOST = 'https://us.i.posthog.com'; export interface AnalyticsContext { - anonymousId: string; + deviceId: string; sessionId: string; clientType: AnalyticsClientType; locale: string; @@ -39,15 +39,15 @@ export interface AnalyticsContext { // web side too). Daemon-internal capture sites (e.g. background sweeps with // no request) should not invoke this path. export function readAnalyticsContext(req: Request): AnalyticsContext | null { - const anonymousId = headerString(req, ANALYTICS_HEADER_ANONYMOUS_ID); - if (!anonymousId) return null; - const sessionId = headerString(req, ANALYTICS_HEADER_SESSION_ID) ?? anonymousId; + const deviceId = headerString(req, ANALYTICS_HEADER_DEVICE_ID); + if (!deviceId) return null; + const sessionId = headerString(req, ANALYTICS_HEADER_SESSION_ID) ?? deviceId; const clientHeader = headerString(req, ANALYTICS_HEADER_CLIENT_TYPE); const clientType: AnalyticsClientType = clientHeader === 'desktop' ? 'desktop' : 'web'; const locale = headerString(req, ANALYTICS_HEADER_LOCALE) ?? 'en'; const requestId = headerString(req, ANALYTICS_HEADER_REQUEST_ID); - return { anonymousId, sessionId, clientType, locale, requestId }; + return { deviceId, sessionId, clientType, locale, requestId }; } function headerString(req: Request, name: string): string | null { @@ -140,7 +140,7 @@ export function createAnalyticsService(args: { const appCfg = await readAppConfig(args.dataDir); if (appCfg.telemetry?.metrics !== true) return; client.capture({ - distinctId: context.anonymousId, + distinctId: context.deviceId, event: eventName, properties: { ...properties, @@ -149,7 +149,8 @@ export function createAnalyticsService(args: { ui_version: appVersion, app_version: appVersion, session_id: context.sessionId, - anonymous_id: context.anonymousId, + // v2 rename: was `anonymous_id`. Value unchanged. + device_id: context.deviceId, client_type: context.clientType, locale: context.locale, ...(context.requestId ? { request_id: context.requestId } : {}), diff --git a/apps/daemon/src/chat-routes.ts b/apps/daemon/src/chat-routes.ts index c11cde5e33..cf91831922 100644 --- a/apps/daemon/src/chat-routes.ts +++ b/apps/daemon/src/chat-routes.ts @@ -1,40 +1,10 @@ import type { Express } from 'express'; import type { RouteDeps } from './server-context.js'; -import { newInsertId } from './analytics.js'; -import { - agentIdToTracking, - projectKindToTracking, -} from '@open-design/contracts/analytics'; +import { projectKindToTracking } from '@open-design/contracts/analytics'; import { validateBaseUrlResolved } from './connectionTest.js'; export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle'> {} -// Invariant: a chat assistant message row reflects its run's terminal state -// even when the web client never persists the cancel/finish itself (refresh -// or dropped PUT). Without this, a row stuck at run_status='running' / -// ended_at=NULL makes the elapsed-time renderer fall back to now - startedAt -// after reload. COALESCE preserves any endedAt the web already wrote; the -// run_status guard skips rows the web has already finalized. -function reconcileAssistantMessageOnRunEnd( - db: RegisterChatRoutesDeps['db'], - runs: RegisterChatRoutesDeps['design']['runs'], - run: { assistantMessageId: string | null }, -): void { - if (!run.assistantMessageId) return; - void runs - .wait(run) - .then((finalStatus: { status: string }) => { - db.prepare( - `UPDATE messages - SET run_status = ?, ended_at = COALESCE(ended_at, ?) - WHERE id = ? AND run_status IN ('queued', 'running')`, - ).run(finalStatus.status, Date.now(), run.assistantMessageId); - }) - .catch((err: unknown) => { - console.warn('[runs] message reconciliation failed', err); - }); -} - export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const { db, design } = ctx; const { sendApiError, createSseResponse } = ctx.http; @@ -67,147 +37,11 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { return false; }; - app.post('/api/runs', (req, res) => { - if (isDaemonShuttingDown()) { - return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down'); - } - const run = design.runs.create(req.body || {}); - const declared = String(req.get('x-od-client') ?? '').toLowerCase(); - if (declared === 'desktop' || declared === 'web') { - run.clientType = declared; - } else { - const ua = String(req.get('user-agent') ?? ''); - run.clientType = ua.includes('Electron/') ? 'desktop' : 'web'; - } - /** @type {import('@open-design/contracts').ChatRunCreateResponse} */ - const body = { runId: run.id }; - res.status(202).json(body); - design.runs.start(run, () => startChatRun(req.body || {}, run)); - reconcileAssistantMessageOnRunEnd(db, design.runs, run); - - // Analytics: emit run_created (daemon-side, authoritative) and - // schedule a run_finished emission on wait() resolution. Both events - // use the same insert_id so PostHog dedupes against the web mirror - // that fires on SSE start/end. No-op when POSTHOG_KEY is unset. - const context = design.readAnalyticsContext?.(req); - if (context) { - const reqBody = (req.body || {}) as Record; - const runInsertId = newInsertId(); - const runStartedAt = Date.now(); - // Estimate user_query_tokens from the request prompt — we never - // transmit the prompt text itself, just the integer count. The - // canonical extraction (currentPrompt fallback to message) lives - // in telemetryPromptFromRunRequest; mirroring it inline keeps the - // analytics emit self-contained and out of the startChatRun - // critical path. - const promptText = - typeof reqBody.currentPrompt === 'string' - ? reqBody.currentPrompt - : typeof reqBody.message === 'string' - ? reqBody.message - : ''; - // ~4 chars per token is the common rough heuristic for English / - // Latin text; CJK skews token-per-char higher but this is still the - // industry-standard estimate when no tokenizer is available. The - // accompanying token_count_source field marks this as 'estimated' - // so dashboards can tell estimate from real provider counts. - const userQueryTokens = promptText.length > 0 - ? Math.ceil(promptText.length / 4) - : 0; - const baseProps: Record = { - page: 'studio', - area: 'chat_composer', - project_id: typeof reqBody.projectId === 'string' ? reqBody.projectId : null, - conversation_id: - typeof reqBody.conversationId === 'string' ? reqBody.conversationId : null, - run_id: run.id, - project_kind: null, - design_system_id: - typeof reqBody.designSystemId === 'string' - ? reqBody.designSystemId - : undefined, - design_system_source: 'unknown', - has_attachment: Array.isArray(reqBody.attachments) - ? (reqBody.attachments as unknown[]).length > 0 - : false, - user_query_tokens: userQueryTokens, - model_id: typeof reqBody.model === 'string' ? reqBody.model : null, - agent_provider_id: - typeof reqBody.agentId === 'string' - ? agentIdToTracking(reqBody.agentId) - : null, - skill_id: typeof reqBody.skillId === 'string' ? reqBody.skillId : null, - mcp_id: null, - token_count_source: userQueryTokens > 0 ? 'estimated' : 'unknown', - }; - design.analytics.capture({ - eventName: 'run_created', - context, - appVersion: design.getAppVersion?.() ?? '0.0.0', - properties: baseProps, - insertId: runInsertId, - }); - // Run lifecycle hook: emit run_finished when the run reaches a - // terminal state. The same context is reused — captures are - // synchronous and never block the run. - design.runs.wait(run).then((status: { status: string }) => { - const result = - status.status === 'succeeded' - ? 'success' - : status.status === 'canceled' - ? 'cancelled' - : 'failed'; - // Pull input/output token totals from the agent's usage event, - // which claude-stream.ts emits as `{ type: 'usage', usage: {...} }` - // and the run service stores in run.events. Provider only gives - // totals (no 7-subfield breakdown), so token_count_source flips - // to 'provider_usage' here only when at least one number landed; - // otherwise stays 'unknown'. - let inputTokens: number | undefined; - let outputTokens: number | undefined; - for (let i = run.events.length - 1; i >= 0; i -= 1) { - const ev = run.events[i]; - const data = ev?.data as - | { type?: string; usage?: Record | null } - | null - | undefined; - if (ev?.event === 'agent' && data?.type === 'usage' && data.usage) { - const u = data.usage; - if (typeof u.input_tokens === 'number') inputTokens = u.input_tokens; - if (typeof u.output_tokens === 'number') outputTokens = u.output_tokens; - if (inputTokens !== undefined || outputTokens !== undefined) break; - } - } - const haveUsage = inputTokens !== undefined || outputTokens !== undefined; - const totalTokens = - inputTokens !== undefined && outputTokens !== undefined - ? inputTokens + outputTokens - : undefined; - design.analytics.capture({ - eventName: 'run_finished', - context, - appVersion: design.getAppVersion?.() ?? '0.0.0', - properties: { - ...baseProps, - area: 'chat_panel', - result, - artifact_count: 0, - total_duration_ms: Date.now() - runStartedAt, - ...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}), - ...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}), - ...(totalTokens !== undefined ? { total_tokens: totalTokens } : {}), - // Upgrade source to 'provider_usage' when the agent reported - // input/output totals; otherwise inherit baseProps' value - // ('estimated' when user_query_tokens > 0, else 'unknown'). - ...(haveUsage ? { token_count_source: 'provider_usage' } : {}), - }, - insertId: `${runInsertId}-finish`, - }); - }).catch(() => { - // wait() can't reject in current runs.ts impl, but guard anyway. - }); - } - }); + // The canonical POST /api/runs handler lives in `server.ts` — it ran + // first in Express's registration order long before this file existed, + // so any handler we wired here was shadowed and never executed. Plugin + // snapshot resolution, clientType inference, and the daemon-side + // run_created/finished analytics all live in `server.ts` now. app.get('/api/runs', (req, res) => { const { projectId, conversationId, status } = req.query; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index ce4e38fe99..2cd2f3061f 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -166,9 +166,11 @@ import { createChatRunService } from './runs.js'; import { reportRunCompletedFromDaemon } from './langfuse-bridge.js'; import { createAnalyticsService, + newInsertId, readAnalyticsContext, readPublicConfigResponse, } from './analytics.js'; +import { agentIdToTracking } from '@open-design/contracts/analytics'; import { redactSecrets, testAgentConnection, @@ -9960,6 +9962,19 @@ export async function startServer({ } } const run = design.runs.create(meta); + // Capture clientType for downstream telemetry (Langfuse uses it on + // run-completed metadata; PostHog gets it via the request header + // bridge). Prefer the explicit `x-od-client` header from desktop / + // web sidecars, fall back to user-agent detection. Without this the + // run object's `clientType` stays undefined and Langfuse traces lose + // the surface dimension. + const declaredClient = String(req.get('x-od-client') ?? '').toLowerCase(); + if (declaredClient === 'desktop' || declaredClient === 'web') { + run.clientType = declaredClient; + } else { + const ua = String(req.get('user-agent') ?? ''); + run.clientType = ua.includes('Electron/') ? 'desktop' : 'web'; + } if (resolvedSnapshot?.ok) { try { const { linkSnapshotToRun } = await import('./plugins/snapshots.js'); @@ -9998,6 +10013,140 @@ export async function startServer({ } reconcileAssistantMessageOnRunEnd(db, design.runs, run); design.runs.start(run, () => startChatRun(meta, run)); + + // Analytics v2: emit run_created (daemon-side authoritative) and + // schedule run_finished on terminal state. The matching `chat-routes.ts` + // handler is shadowed by this earlier registration in Express; emit + // here so PostHog actually receives the event. Both fire under the + // same insert_id prefix so any web-side mirror dedupes by $insert_id. + const analyticsContext = readAnalyticsContext(req); + if (analyticsContext) { + const reqBody = (req.body || {}) as Record; + const runInsertId = newInsertId(); + const runStartedAt = Date.now(); + const promptText = + typeof reqBody.currentPrompt === 'string' + ? reqBody.currentPrompt + : typeof reqBody.message === 'string' + ? reqBody.message + : ''; + const userQueryTokens = promptText.length > 0 + ? Math.ceil(promptText.length / 4) + : 0; + const stringField = (key: string): string | undefined => + typeof reqBody[key] === 'string' ? (reqBody[key] as string) : undefined; + const booleanField = (key: string): boolean | undefined => + typeof reqBody[key] === 'boolean' ? (reqBody[key] as boolean) : undefined; + const baseProps: Record = { + page_name: 'chat_panel', + area: 'chat_composer', + project_id: typeof reqBody.projectId === 'string' ? reqBody.projectId : null, + conversation_id: + typeof reqBody.conversationId === 'string' ? reqBody.conversationId : null, + run_id: run.id, + project_kind: stringField('projectKind') ?? null, + design_system_id: stringField('designSystemId'), + design_system_source: stringField('designSystemSource') ?? 'unknown', + design_system_version: stringField('designSystemVersion'), + entry_from: stringField('entryFrom'), + project_source: stringField('projectSource'), + target_platforms: stringField('targetPlatforms'), + companion_surfaces: stringField('companionSurfaces'), + fidelity: stringField('fidelity'), + connectors: stringField('connectors'), + use_speaker_notes: booleanField('useSpeakerNotes'), + include_animations: booleanField('includeAnimations'), + reference_template: stringField('referenceTemplate'), + aspect: stringField('aspect'), + has_attachment: Array.isArray(reqBody.attachments) + ? (reqBody.attachments as unknown[]).length > 0 + : false, + user_query_tokens: userQueryTokens, + model_id: typeof reqBody.model === 'string' ? reqBody.model : null, + agent_provider_id: + typeof reqBody.agentId === 'string' + ? agentIdToTracking(reqBody.agentId) + : null, + skill_id: typeof reqBody.skillId === 'string' ? reqBody.skillId : null, + mcp_id: null, + token_count_source: userQueryTokens > 0 ? 'estimated' : 'unknown', + }; + design.analytics.capture({ + eventName: 'run_created', + context: analyticsContext, + appVersion: design.getAppVersion(), + properties: baseProps, + insertId: runInsertId, + }); + design.runs.wait(run).then((status: { + status: string; + error?: string | null; + errorCode?: string | null; + exitCode?: number | null; + signal?: string | null; + }) => { + const result = + status.status === 'succeeded' + ? 'success' + : status.status === 'canceled' + ? 'cancelled' + : 'failed'; + let errorCode: string | undefined; + if (result === 'failed') { + errorCode = status.errorCode ?? undefined; + if (!errorCode) { + if (status.signal) errorCode = `AGENT_SIGNAL_${status.signal}`; + else if (typeof status.exitCode === 'number' && status.exitCode !== 0) { + errorCode = `AGENT_EXIT_${status.exitCode}`; + } else { + errorCode = 'AGENT_TERMINATED_UNKNOWN'; + } + } + } else if (result === 'cancelled') { + errorCode = status.errorCode ?? undefined; + } + let inputTokens: number | undefined; + let outputTokens: number | undefined; + for (let i = run.events.length - 1; i >= 0; i -= 1) { + const ev = run.events[i]; + const data = ev?.data as + | { type?: string; usage?: Record | null } + | null + | undefined; + if (ev?.event === 'agent' && data?.type === 'usage' && data.usage) { + const u = data.usage; + if (typeof u.input_tokens === 'number') inputTokens = u.input_tokens; + if (typeof u.output_tokens === 'number') outputTokens = u.output_tokens; + if (inputTokens !== undefined || outputTokens !== undefined) break; + } + } + const haveUsage = inputTokens !== undefined || outputTokens !== undefined; + const totalTokens = + inputTokens !== undefined && outputTokens !== undefined + ? inputTokens + outputTokens + : undefined; + design.analytics.capture({ + eventName: 'run_finished', + context: analyticsContext, + appVersion: design.getAppVersion(), + properties: { + ...baseProps, + area: 'chat_panel', + result, + artifact_count: 0, + total_duration_ms: Date.now() - runStartedAt, + ...(errorCode ? { error_code: errorCode } : {}), + ...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}), + ...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}), + ...(totalTokens !== undefined ? { total_tokens: totalTokens } : {}), + ...(haveUsage ? { token_count_source: 'provider_usage' } : {}), + }, + insertId: `${runInsertId}-finish`, + }); + }).catch(() => { + // wait() can't reject in current runs.ts impl, but guard anyway. + }); + } }); app.get('/api/runs', (req, res) => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bb0309878c..70c43f6180 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useAnalytics } from './analytics/provider'; -import { trackAppLaunch, trackProjectCreateResult } from './analytics/events'; -import { detectClientType, detectLaunchSource } from './analytics/identity'; +import { trackProjectCreateResult } from './analytics/events'; +import { detectClientType } from './analytics/identity'; import { projectKindToTracking, fidelityToTracking, @@ -231,22 +231,11 @@ export function App() { const route = useRoute(); const analytics = useAnalytics(); - // app_launch — fired exactly once per page load. Mounting in App, not the - // RootLayout, so we capture after the first React tick and the analytics - // provider has had a chance to wire its identity. Gated on - // `config.telemetry?.metrics` so a freshly-opted-in user gets the event - // on their next reload, and a declined user fires nothing. - const appLaunchFiredRef = useRef(false); - useEffect(() => { - if (appLaunchFiredRef.current) return; - if (config.telemetry?.metrics !== true) return; - appLaunchFiredRef.current = true; - trackAppLaunch(analytics.track, { - page: 'app', - launch_source: detectLaunchSource(), - platform: detectClientType(), - }); - }, [analytics.track, config.telemetry?.metrics]); + // v2 schema removed the standalone `app_launch` event; the initial + // page_view fires from each top-level page surface (home / projects / + // automations / plugins / design_systems / integrations) instead. + // `detectClientType` still feeds analytics identity via the provider. + void detectClientType; // Propagate the Privacy toggle through to PostHog without a reload — // posthog-js's opt_out_capturing flips a localStorage flag that makes @@ -768,12 +757,11 @@ export function App() { trackProjectCreateResult( analytics.track, { - page: 'home', - area: 'create_panel', - action_source: 'create_button', + page_name: 'home', + area: 'new_project', + project_source: 'create_button', project_id: null, project_kind: projectKindToTracking(kind), - creation_source: creationSource, fidelity, result: 'failed', error_code: 'CREATE_REQUEST_FAILED', @@ -796,12 +784,11 @@ export function App() { trackProjectCreateResult( analytics.track, { - page: 'home', - area: 'create_panel', - action_source: 'create_button', + page_name: 'home', + area: 'new_project', + project_source: 'create_button', project_id: result.project.id, project_kind: projectKindToTracking(kind), - creation_source: creationSource, fidelity, result: 'success', }, diff --git a/apps/web/src/analytics/client.ts b/apps/web/src/analytics/client.ts index 55507c0248..57b86dbff4 100644 --- a/apps/web/src/analytics/client.ts +++ b/apps/web/src/analytics/client.ts @@ -8,6 +8,7 @@ import { EVENT_SCHEMA_VERSION, type AnalyticsClientType, type AnalyticsConfigResponse, + type AnalyticsConfigureGlobals, } from '@open-design/contracts/analytics'; import { scrubBeforeSend } from './scrub'; @@ -21,14 +22,49 @@ interface AnalyticsContext { let client: PostHog | null = null; let initPromise: Promise | null = null; -let resolvedAnonymousId: string | null = null; +let resolvedDeviceId: string | null = null; +// Latest configure-state triplet. Re-registered on the PostHog client as +// soon as it changes so every subsequent event inherits the current values. +let configureGlobals: AnalyticsConfigureGlobals = { + has_available_configure_cli: false, + configure_type: 'unknown', + configure_availability: 'unknown', +}; // Returns the installationId the daemon stamped on /api/analytics/config // after the user opted in via Privacy → "Share usage data". The provider // uses this in preference to its locally-generated UUID so PostHog, // Langfuse, and any future sink share a single anonymous identity. +// +// Kept under the legacy name for callers that still import it; new code +// should prefer `getResolvedDeviceId`. export function getResolvedAnonymousId(): string | null { - return resolvedAnonymousId; + return resolvedDeviceId; +} + +export function getResolvedDeviceId(): string | null { + return resolvedDeviceId; +} + +// Web-side accessor for the daemon header bridge: when the web client POSTs +// to /api/runs the daemon needs to know what device_id to stamp on its +// own server-side captures. +export function getConfigureGlobals(): AnalyticsConfigureGlobals { + return configureGlobals; +} + +// Called from the AnalyticsProvider when the configure-state triplet changes +// (mode switch, BYOK key save, CLI rescan). The values are registered on the +// PostHog client so every subsequent capture inherits them — no per-event +// boilerplate needed. +export function setConfigureGlobals(next: AnalyticsConfigureGlobals): void { + configureGlobals = { ...next }; + if (!client) return; + try { + client.register(configureGlobals as unknown as Record); + } catch { + // best-effort — capture should never throw out of this path. + } } export async function getAnalyticsClient( @@ -52,7 +88,7 @@ export async function getAnalyticsClient( const distinctId = (typeof cfg.installationId === 'string' && cfg.installationId) || context.anonymousId; - resolvedAnonymousId = distinctId; + resolvedDeviceId = distinctId; const mod = await import('posthog-js'); const posthog = mod.default; posthog.init(cfg.key, { @@ -63,6 +99,14 @@ export async function getAnalyticsClient( // locally-generated UUID for the legacy / pre-consent path. bootstrap: { distinctID: distinctId }, persistence: 'localStorage', + // PostHog's default UA filter silently drops captures whose + // user-agent matches its built-in bot list (HeadlessChrome, + // various automation flags). The list also rejects some real users + // — embedded webviews, fingerprinted browsers, e2e CI runs — which + // is unacceptable for product analytics that needs to count every + // session. We instead rely on the Privacy → "Share usage data" + // toggle as the single consent gate and treat every UA equally. + opt_out_useragent_filter: true, // --- Auto-capture layers -------------------------------------- // Anonymous diagnostic features (click paths, page transitions, @@ -111,7 +155,10 @@ export async function getAnalyticsClient( client_type: context.clientType, locale: context.locale, session_id: context.sessionId, - anonymous_id: distinctId, + // v2 rename: was `anonymous_id`. Value is unchanged — the same + // installationId / local-UUID fallback. + device_id: distinctId, + ...(configureGlobals as unknown as Record), }); }, }); @@ -156,7 +203,7 @@ export function applyConsent(consentGranted: boolean): void { } else { client.opt_out_capturing(); client.reset(); - resolvedAnonymousId = null; + resolvedDeviceId = null; } } catch { // best-effort — capture should never throw out of this path. @@ -171,11 +218,11 @@ export function applyConsent(consentGranted: boolean): void { // session is fully decoupled from the deleted one. export function applyIdentity(installationId: string | null): void { if (!client || !installationId) return; - if (resolvedAnonymousId === installationId) return; + if (resolvedDeviceId === installationId) return; try { client.reset(); client.identify(installationId); - resolvedAnonymousId = installationId; + resolvedDeviceId = installationId; } catch { // best-effort — never propagate. } diff --git a/apps/web/src/analytics/events.ts b/apps/web/src/analytics/events.ts index f43503f137..02e8c95843 100644 --- a/apps/web/src/analytics/events.ts +++ b/apps/web/src/analytics/events.ts @@ -1,177 +1,616 @@ -// Typed track* helpers — one per P0 event. The helpers themselves don't -// hit PostHog directly; they marshal the typed props into the loosely-typed -// `track()` from the AnalyticsProvider so the event names + property shapes -// live in @open-design/contracts/analytics and stay in lockstep with the -// daemon side. +// Typed track* helpers for the v2 analytics schema. Each helper accepts a +// strongly typed props payload (from @open-design/contracts/analytics) and +// forwards it through the loosely typed `track()` from AnalyticsProvider. +// Keeping the event-name → prop-shape coupling in one place means call sites +// stay short and stay in lockstep with the daemon-side capture. import type { - AppLaunchProps, - ArtifactExportResultProps, - HomeClickCreateButtonProps, - HomeViewAssetPanelProps, - HomeViewPageProps, + // page_view / surface_view + PageViewProps, + HelpPopoverSurfaceViewProps, + NewProjectModalSurfaceViewProps, + PluginReplacementModalSurfaceViewProps, + DesignSystemsTemplatesModalSurfaceViewProps, + AssistantFeedbackReasonPanelSurfaceViewProps, + // ui_click + HomeNavClickProps, + HelpPopoverClickProps, + HomeToolbarClickProps, + ExecutionSettingsPopoverClickProps, + SettingsPopoverClickProps, + HomeChatComposerClickProps, + NewProjectModalTabClickProps, + NewProjectModalElementClickProps, + PluginReplacementModalClickProps, + PrivacyModalClickProps, + RecentProjectsClickProps, + HomeTemplatesClickProps, + HomeTemplatesDropdownClickProps, + ProjectsListControlsClickProps, + ProjectsListClickProps, + ProjectsMorePopoverClickProps, + AutomationsClickProps, + PluginsTopClickProps, + PluginsInstalledTabClickProps, + PluginsTemplatesDropdownClickProps, + PluginsAvailableTabClickProps, + PluginsSourcesTabClickProps, + DesignSystemsTopClickProps, + DesignSystemsTemplateCardClickProps, + DesignSystemsTemplatesModalClickProps, + DesignSystemsTemplatesModalSharePopoverClickProps, + IntegrationsTabClickProps, + IntegrationsMcpTabClickProps, + IntegrationsConnectorsTabClickProps, + IntegrationsSkillsTabClickProps, + IntegrationsUseEverywhereTabClickProps, + ChatPanelClickProps, + ChatPanelResourcesPopoverClickProps, + FileManagerClickProps, + ArtifactToolbarClickProps, + TweaksPopoverClickProps, + ArtifactHeaderClickProps, + PresentPopoverClickProps, + ShareOptionPopoverClickProps, + AssistantFeedbackButtonClickProps, + AssistantFeedbackReasonSubmitClickProps, + SettingsSidebarClickProps, + SettingsExecutionModeTabClickProps, + SettingsLocalCliClickProps, + SettingsByokProviderOptionClickProps, + SettingsByokFieldClickProps, + SettingsMediaProvidersClickProps, + SettingsConnectorsClickProps, + SettingsLanguageClickProps, + SettingsAppearanceClickProps, + SettingsNotificationsClickProps, + SettingsPetsClickProps, + SettingsPrivacyClickProps, + // Result events ProjectCreateResultProps, + PluginReplacementResultProps, RunCreatedProps, RunFinishedProps, - SettingsByokTestResultProps, - SettingsClickByokFieldProps, - SettingsClickByokProviderOptionProps, - SettingsClickCliProviderCardProps, - SettingsClickExecutionModeTabProps, - SettingsCliTestResultProps, + FileUploadResultProps, + ArtifactExportResultProps, + FeedbackSubmitResultProps, SettingsViewProps, - StudioClickChatComposerProps, - StudioClickShareOptionProps, - StudioViewArtifactProps, - StudioViewChatPanelProps, + SettingsCliTestResultProps, + SettingsByokTestResultProps, + SettingsConnectorAuthResultProps, } from '@open-design/contracts/analytics'; +type TrackOptions = { requestId?: string; insertId?: string }; type Track = ( event: string, properties: Record, - options?: { requestId?: string; insertId?: string }, + options?: TrackOptions, ) => void; -export function trackAppLaunch(track: Track, props: AppLaunchProps) { - track('app_launch', props as unknown as Record); +// Helper: forward a typed payload to the loose `track()` API. Centralized so +// every call site stays one-line. +function send( + track: Track, + event: string, + props: T, + options?: TrackOptions, +): void { + track(event, props as unknown as Record, options); } -export function trackHomeViewPage(track: Track, props: HomeViewPageProps) { - track('home_view', props as unknown as Record); +// ---- page_view ----------------------------------------------------------- + +export function trackPageView(track: Track, props: PageViewProps): void { + send(track, 'page_view', props); } -export function trackHomeViewAssetPanel( +// ---- surface_view -------------------------------------------------------- + +export function trackHelpPopoverSurfaceView( track: Track, - props: HomeViewAssetPanelProps, -) { - track('home_view', props as unknown as Record); + props: HelpPopoverSurfaceViewProps, +): void { + send(track, 'surface_view', props); } -export function trackHomeClickCreateButton( +export function trackNewProjectModalSurfaceView( track: Track, - props: HomeClickCreateButtonProps, - options?: { requestId: string }, -) { - track('home_click', props as unknown as Record, options); + props: NewProjectModalSurfaceViewProps, +): void { + send(track, 'surface_view', props); } -export function trackProjectCreateResult( +export function trackPluginReplacementModalSurfaceView( track: Track, - props: ProjectCreateResultProps, - options?: { requestId?: string }, -) { - track( - 'project_create_result', - props as unknown as Record, - options, - ); + props: PluginReplacementModalSurfaceViewProps, +): void { + send(track, 'surface_view', props); } -export function trackSettingsView(track: Track, props: SettingsViewProps) { - track('settings_view', props as unknown as Record); +export function trackDesignSystemsTemplatesModalSurfaceView( + track: Track, + props: DesignSystemsTemplatesModalSurfaceViewProps, +): void { + send(track, 'surface_view', props); } -export function trackSettingsClickExecutionModeTab( +export function trackAssistantFeedbackReasonPanelSurfaceView( track: Track, - props: SettingsClickExecutionModeTabProps, -) { - track('settings_click', props as unknown as Record); + props: AssistantFeedbackReasonPanelSurfaceViewProps, +): void { + send(track, 'surface_view', props); } -export function trackSettingsClickCliProviderCard( +// ---- ui_click (home) ----------------------------------------------------- + +export function trackHomeNavClick( track: Track, - props: SettingsClickCliProviderCardProps, -) { - track('settings_click', props as unknown as Record); + props: HomeNavClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickByokField( +export function trackHelpPopoverClick( track: Track, - props: SettingsClickByokFieldProps, -) { - track('settings_click', props as unknown as Record); + props: HelpPopoverClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickByokProviderOption( +export function trackHomeToolbarClick( track: Track, - props: SettingsClickByokProviderOptionProps, -) { - track('settings_click', props as unknown as Record); + props: HomeToolbarClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsCliTestResult( +export function trackExecutionSettingsPopoverClick( track: Track, - props: SettingsCliTestResultProps, -) { - track( - 'settings_cli_test_result', - props as unknown as Record, - ); + props: ExecutionSettingsPopoverClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsByokTestResult( +export function trackSettingsPopoverClick( track: Track, - props: SettingsByokTestResultProps, -) { - track( - 'settings_byok_test_result', - props as unknown as Record, - ); + props: SettingsPopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackHomeChatComposerClick( + track: Track, + props: HomeChatComposerClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackNewProjectModalTabClick( + track: Track, + props: NewProjectModalTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackNewProjectModalElementClick( + track: Track, + props: NewProjectModalElementClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginReplacementModalClick( + track: Track, + props: PluginReplacementModalClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPrivacyModalClick( + track: Track, + props: PrivacyModalClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackRecentProjectsClick( + track: Track, + props: RecentProjectsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackHomeTemplatesClick( + track: Track, + props: HomeTemplatesClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackHomeTemplatesDropdownClick( + track: Track, + props: HomeTemplatesDropdownClickProps, +): void { + send(track, 'ui_click', props); +} + +// ---- ui_click (projects) ------------------------------------------------- + +export function trackProjectsListControlsClick( + track: Track, + props: ProjectsListControlsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackProjectsListClick( + track: Track, + props: ProjectsListClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackProjectsMorePopoverClick( + track: Track, + props: ProjectsMorePopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +// ---- ui_click (automations / plugins / design_systems / integrations) ---- + +export function trackAutomationsClick( + track: Track, + props: AutomationsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginsTopClick( + track: Track, + props: PluginsTopClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginsInstalledTabClick( + track: Track, + props: PluginsInstalledTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginsTemplatesDropdownClick( + track: Track, + props: PluginsTemplatesDropdownClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginsAvailableTabClick( + track: Track, + props: PluginsAvailableTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPluginsSourcesTabClick( + track: Track, + props: PluginsSourcesTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackDesignSystemsTopClick( + track: Track, + props: DesignSystemsTopClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackDesignSystemsTemplateCardClick( + track: Track, + props: DesignSystemsTemplateCardClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackDesignSystemsTemplatesModalClick( + track: Track, + props: DesignSystemsTemplatesModalClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackDesignSystemsTemplatesModalSharePopoverClick( + track: Track, + props: DesignSystemsTemplatesModalSharePopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackIntegrationsTabClick( + track: Track, + props: IntegrationsTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackIntegrationsMcpTabClick( + track: Track, + props: IntegrationsMcpTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackIntegrationsConnectorsTabClick( + track: Track, + props: IntegrationsConnectorsTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackIntegrationsSkillsTabClick( + track: Track, + props: IntegrationsSkillsTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackIntegrationsUseEverywhereTabClick( + track: Track, + props: IntegrationsUseEverywhereTabClickProps, +): void { + send(track, 'ui_click', props); } -export function trackStudioViewChatPanel( +// ---- ui_click (chat panel) ----------------------------------------------- + +export function trackChatPanelClick( track: Track, - props: StudioViewChatPanelProps, -) { - track('studio_view', props as unknown as Record); + props: ChatPanelClickProps, +): void { + send(track, 'ui_click', props); } -export function trackStudioClickChatComposer( +export function trackChatPanelResourcesPopoverClick( track: Track, - props: StudioClickChatComposerProps, -) { - track('studio_click', props as unknown as Record); + props: ChatPanelResourcesPopoverClickProps, +): void { + send(track, 'ui_click', props); } -export function trackStudioViewArtifact( +// ---- ui_click (file manager / artifact) ---------------------------------- + +export function trackFileManagerClick( track: Track, - props: StudioViewArtifactProps, -) { - track('studio_view', props as unknown as Record); + props: FileManagerClickProps, +): void { + send(track, 'ui_click', props); } -export function trackStudioClickShareOption( +export function trackArtifactToolbarClick( track: Track, - props: StudioClickShareOptionProps, + props: ArtifactToolbarClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackTweaksPopoverClick( + track: Track, + props: TweaksPopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackArtifactHeaderClick( + track: Track, + props: ArtifactHeaderClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackPresentPopoverClick( + track: Track, + props: PresentPopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackShareOptionPopoverClick( + track: Track, + props: ShareOptionPopoverClickProps, options?: { requestId: string }, -) { - track('studio_click', props as unknown as Record, options); +): void { + send(track, 'ui_click', props, options); } -export function trackArtifactExportResult( +// ---- ui_click (feedback) ------------------------------------------------- + +export function trackAssistantFeedbackButtonClick( track: Track, - props: ArtifactExportResultProps, + props: AssistantFeedbackButtonClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackAssistantFeedbackReasonSubmitClick( + track: Track, + props: AssistantFeedbackReasonSubmitClickProps, +): void { + send(track, 'ui_click', props); +} + +// ---- ui_click (settings) ------------------------------------------------- + +export function trackSettingsSidebarClick( + track: Track, + props: SettingsSidebarClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsExecutionModeTabClick( + track: Track, + props: SettingsExecutionModeTabClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsLocalCliClick( + track: Track, + props: SettingsLocalCliClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsByokProviderOptionClick( + track: Track, + props: SettingsByokProviderOptionClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsByokFieldClick( + track: Track, + props: SettingsByokFieldClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsMediaProvidersClick( + track: Track, + props: SettingsMediaProvidersClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsConnectorsClick( + track: Track, + props: SettingsConnectorsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsLanguageClick( + track: Track, + props: SettingsLanguageClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsAppearanceClick( + track: Track, + props: SettingsAppearanceClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsNotificationsClick( + track: Track, + props: SettingsNotificationsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsPetsClick( + track: Track, + props: SettingsPetsClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsPrivacyClick( + track: Track, + props: SettingsPrivacyClickProps, +): void { + send(track, 'ui_click', props); +} + +// ---- Result events ------------------------------------------------------- + +export function trackProjectCreateResult( + track: Track, + props: ProjectCreateResultProps, + options?: { requestId?: string }, +): void { + send(track, 'project_create_result', props, options); +} + +export function trackPluginReplacementResult( + track: Track, + props: PluginReplacementResultProps, options?: { requestId?: string }, -) { - track( - 'artifact_export_result', - props as unknown as Record, - options, - ); +): void { + send(track, 'plugin_replacement_result', props, options); } export function trackRunCreated( track: Track, props: RunCreatedProps, options?: { requestId?: string }, -) { - track('run_created', props as unknown as Record, options); +): void { + send(track, 'run_created', props, options); } export function trackRunFinished( track: Track, props: RunFinishedProps, options?: { requestId?: string }, -) { - track('run_finished', props as unknown as Record, options); +): void { + send(track, 'run_finished', props, options); +} + +export function trackFileUploadResult( + track: Track, + props: FileUploadResultProps, + options?: { requestId?: string }, +): void { + send(track, 'file_upload_result', props, options); +} + +export function trackArtifactExportResult( + track: Track, + props: ArtifactExportResultProps, + options?: { requestId?: string }, +): void { + send(track, 'artifact_export_result', props, options); +} + +export function trackFeedbackSubmitResult( + track: Track, + props: FeedbackSubmitResultProps, + options?: { requestId?: string }, +): void { + send(track, 'feedback_submit_result', props, options); +} + +// ---- Settings view + test/auth result events ----------------------------- + +export function trackSettingsView( + track: Track, + props: SettingsViewProps, +): void { + send(track, 'settings_view', props); +} + +export function trackSettingsCliTestResult( + track: Track, + props: SettingsCliTestResultProps, +): void { + send(track, 'settings_cli_test_result', props); +} + +export function trackSettingsByokTestResult( + track: Track, + props: SettingsByokTestResultProps, +): void { + send(track, 'settings_byok_test_result', props); +} + +export function trackSettingsConnectorAuthResult( + track: Track, + props: SettingsConnectorAuthResultProps, +): void { + send(track, 'settings_connector_auth_result', props); } diff --git a/apps/web/src/analytics/provider.tsx b/apps/web/src/analytics/provider.tsx index 4bad195631..de318b456a 100644 --- a/apps/web/src/analytics/provider.tsx +++ b/apps/web/src/analytics/provider.tsx @@ -12,7 +12,7 @@ import { } from 'react'; import { useI18n } from '../i18n'; import { - ANALYTICS_HEADER_ANONYMOUS_ID, + ANALYTICS_HEADER_DEVICE_ID, ANALYTICS_HEADER_CLIENT_TYPE, ANALYTICS_HEADER_LOCALE, ANALYTICS_HEADER_REQUEST_ID, @@ -157,7 +157,7 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) { if (!resolvedAnonId) return; const original = window.fetch; const baseHeaders: Record = { - [ANALYTICS_HEADER_ANONYMOUS_ID]: resolvedAnonId, + [ANALYTICS_HEADER_DEVICE_ID]: resolvedAnonId, [ANALYTICS_HEADER_SESSION_ID]: identity.sessionId, [ANALYTICS_HEADER_CLIENT_TYPE]: identity.clientType, }; diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 35ab885305..ffda0afad7 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -4,6 +4,14 @@ import { FileOpsSummary } from "./FileOpsSummary"; import { renderMarkdown } from "../runtime/markdown"; import { projectFileUrl } from "../providers/registry"; import { submitChatRunToolResult } from "../providers/daemon"; +import { useAnalytics } from "../analytics/provider"; +import { + trackAssistantFeedbackButtonClick, + trackAssistantFeedbackReasonPanelSurfaceView, + trackAssistantFeedbackReasonSubmitClick, + trackFeedbackSubmitResult, +} from "../analytics/events"; +import type { TrackingProjectKind } from "@open-design/contracts/analytics"; import { splitOnQuestionForms, type QuestionForm, @@ -44,6 +52,11 @@ interface Props { message: ChatMessage; streaming: boolean; projectId: string | null; + // Analytics context for the assistant_feedback_* events. Defaults + // applied at the call site keep AssistantMessage usable in tests + // that don't care about telemetry. + projectKind?: TrackingProjectKind | null; + conversationId?: string | null; projectFiles?: ProjectFile[]; projectFileNames?: Set; onRequestOpenFile?: (name: string) => void; @@ -80,6 +93,8 @@ export function AssistantMessage({ message, streaming, projectId, + projectKind = null, + conversationId = null, projectFiles = [], projectFileNames, onRequestOpenFile, @@ -258,6 +273,12 @@ export function AssistantMessage({ void; footerProps: AssistantFooterProps; + projectId: string | null; + projectKind: TrackingProjectKind | null; + conversationId: string | null; + runId: string | null; + assistantMessageId: string; + producedFileCount: number; }) { const t = useT(); + const analytics = useAnalytics(); + // P0 — analytics context the feedback events need. The four ids are + // either user-anchored (projectId / assistantMessageId) or run-anchored + // (runId), so we pass them down with a stable identity. `producedFileCount` + // feeds `has_produced_files` on assistant_feedback_button click. const [burstKey, setBurstKey] = useState(0); const [reasonRating, setReasonRating] = useState(null); @@ -482,13 +520,56 @@ function AssistantFeedback({ useEffect(() => { if (!reasonRating) return; reasonsRef.current?.scrollIntoView({ block: "start", behavior: "smooth" }); - }, [reasonRating]); + // P0 surface_view assistant_feedback_reason_panel — fires when the + // reason panel actually appears (reasonRating flips from null to + // truthy), not when the buttons render. + trackAssistantFeedbackReasonPanelSurfaceView(analytics.track, { + page_name: "chat_panel", + area: "chat_panel", + element: "assistant_feedback_reason_panel", + view_type: "panel", + project_id: projectId ?? "", + project_kind: projectKind, + conversation_id: conversationId, + assistant_message_id: assistantMessageId, + run_id: runId ?? "", + rating: reasonRating, + }); + }, [ + reasonRating, + analytics.track, + projectId, + projectKind, + conversationId, + assistantMessageId, + runId, + ]); const toggleFeedback = (rating: ChatMessageFeedbackRating) => { const nextRating = selected === rating ? null : rating; if (nextRating === "positive") setBurstKey((key) => key + 1); setDraftReasonCodes(new Set()); setCustomReason(""); setReasonRating(nextRating); + // P0 ui_click assistant_feedback_button. v1 emitted `rating: null` on + // the clear path, which lost the signal "user un-thumbed positive vs + // un-thumbed negative". v2 fixes this: when clearing, `rating` carries + // the rating that was cleared (the user's most recent gesture target), + // and `rating_before` records the previous selection state. + const ratingBefore: "positive" | "negative" | "none" = selected ?? "none"; + trackAssistantFeedbackButtonClick(analytics.track, { + page_name: "chat_panel", + area: "chat_panel", + element: "assistant_feedback_button", + action: nextRating ? "submit_feedback_rating" : "clear_feedback_rating", + project_id: projectId ?? "", + project_kind: projectKind, + conversation_id: conversationId, + assistant_message_id: assistantMessageId, + run_id: runId ?? "", + rating, + rating_before: ratingBefore, + has_produced_files: producedFileCount > 0, + }); onFeedback(nextRating ? { rating: nextRating } : null); }; const toggleReasonCode = (code: ChatMessageFeedbackReasonCode) => { @@ -504,9 +585,60 @@ function AssistantFeedback({ const submitReasons = () => { if (!reasonRating) return; const trimmedCustomReason = customReason.trim(); + const reasonCodes = [...draftReasonCodes]; + const reasonJoined = reasonCodes.length > 0 ? reasonCodes.join(",") : undefined; + const hasCustomReason = draftReasonCodes.has("other") && trimmedCustomReason.length > 0; + const requestId = analytics.newRequestId(); + // P0 ui_click element=assistant_feedback_reason_submit_button — fires + // synchronously on the user gesture so the click count never depends on + // the host's onFeedback persistence resolving. + trackAssistantFeedbackReasonSubmitClick( + analytics.track, + { + page_name: "chat_panel", + area: "chat_panel", + element: "assistant_feedback_reason_submit_button", + action: "click_submit_feedback_reason", + project_id: projectId ?? "", + project_kind: projectKind, + conversation_id: conversationId, + assistant_message_id: assistantMessageId, + run_id: runId ?? "", + rating: reasonRating, + ...(reasonJoined ? { reason: reasonJoined } : {}), + reason_count: reasonCodes.length, + has_custom_reason: hasCustomReason, + ...(hasCustomReason ? { custom_reason: trimmedCustomReason } : {}), + }, + ); + // P0 feedback_submit_result — paired with the click via requestId so + // PostHog dashboards can correlate intent → persistence. onFeedback in + // our app currently completes synchronously, so we emit `success` + // optimistically; a future error-aware host can flip this to `failed`. + trackFeedbackSubmitResult( + analytics.track, + { + page_name: "chat_panel", + area: "chat_panel", + element: "assistant_feedback_reason_submit", + action: "submit_feedback_reason", + project_id: projectId ?? "", + project_kind: projectKind, + conversation_id: conversationId, + assistant_message_id: assistantMessageId, + run_id: runId ?? "", + rating: reasonRating, + ...(reasonJoined ? { reason: reasonJoined } : {}), + reason_count: reasonCodes.length, + has_custom_reason: hasCustomReason, + ...(hasCustomReason ? { custom_reason: trimmedCustomReason } : {}), + result: "success", + }, + { requestId }, + ); onFeedback({ rating: reasonRating, - reasonCodes: [...draftReasonCodes], + reasonCodes, customReason: draftReasonCodes.has("other") && trimmedCustomReason ? trimmedCustomReason diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index 2f5c91a836..303d1a78a6 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -11,8 +11,8 @@ import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { useAnalytics } from '../analytics/provider'; import { - trackStudioClickChatComposer, - trackStudioViewChatPanel, + trackChatPanelClick, + trackPageView, } from '../analytics/events'; import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry"; import { patchProject } from "../state/projects"; @@ -203,13 +203,13 @@ export const ChatComposer = forwardRef( useEffect(() => { if (studioViewFiredRef.current === projectId) return; studioViewFiredRef.current = projectId; - trackStudioViewChatPanel(analytics.track, { - page: 'studio', - area: 'chat_panel', - element: 'chat_tab', - view_type: 'panel', - source: 'open_project', - conversation_id: null, + trackPageView(analytics.track, { + page_name: 'chat_panel', + // `source` records which entry surface launched the studio. The + // ProjectView path defaults to 'recent_project'; helpers that + // navigate from the New project modal pass 'new_project' through + // the route state instead. + source: 'recent_project', }); }, [projectId, analytics.track]); const [staged, setStaged] = useState([]); @@ -1278,7 +1278,23 @@ export const ChatComposer = forwardRef( ref={toolsTriggerRef} type="button" className={`icon-btn composer-tools-trigger${toolsOpen ? ' active' : ''}`} - onClick={() => setToolsOpen((v) => !v)} + onClick={() => { + setToolsOpen((v) => { + const next = !v; + if (next) { + // P0 ui_click resources_popover_trigger — only emit on + // the open transition so accidental double-clicks + // don't pair an open + close into a "double tap" the + // dashboard can't interpret. + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'resources_popover_trigger', + }); + } + return next; + }); + }} title={t('chat.cliSettingsTitle')} aria-haspopup="menu" aria-expanded={toolsOpen} @@ -1431,6 +1447,11 @@ export const ChatComposer = forwardRef( role="menuitem" className="composer-tools-settings" onClick={() => { + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'composer_settings', + }); setToolsOpen(false); onOpenSettings?.(); }} @@ -1446,13 +1467,10 @@ export const ChatComposer = forwardRef( className="icon-btn" data-testid="chat-attach" onClick={() => { - trackStudioClickChatComposer(analytics.track, { - page: 'studio', - area: 'chat_composer', - element: 'attachment_button', - action: 'click_composer_control', - user_query_tokens: Math.ceil(draft.length / 4), - has_attachment: staged.length > 0 || commentAttachments.length > 0, + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'attachment', }); fileInputRef.current?.click(); }} @@ -1482,14 +1500,10 @@ export const ChatComposer = forwardRef( className="composer-send" data-testid="chat-send" onClick={() => { - trackStudioClickChatComposer(analytics.track, { - page: 'studio', - area: 'chat_composer', - element: 'send_button', - action: 'click_composer_control', - user_query_tokens: Math.ceil(draft.length / 4), - has_attachment: - staged.length > 0 || currentCommentAttachments().length > 0, + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'send', }); void submit(); }} diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 7c55493a56..4b07c632c8 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -1,10 +1,13 @@ import { Fragment, useEffect, useRef, useState } from 'react'; +import { useAnalytics } from '../analytics/provider'; +import { trackChatPanelClick } from '../analytics/events'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { copyToClipboard } from '../lib/copy-to-clipboard'; import { projectRawUrl } from '../providers/registry'; import type { TodoItem } from '../runtime/todos'; import type { AppliedPluginSnapshot } from '@open-design/contracts'; +import type { TrackingProjectKind } from '@open-design/contracts/analytics'; import { DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION, DESIGN_SYSTEM_WORKSPACE_DISPLAY_TITLE, @@ -208,6 +211,11 @@ interface Props { streaming: boolean; error: string | null; projectId: string | null; + // Analytics-only — forwarded to AssistantMessage so the feedback + // events know which project surface the rating applies to. Optional + // (defaults to null/'prototype') so unit tests can mount ChatPane + // without project context. + projectKindForTracking?: TrackingProjectKind | null; projectFiles: ProjectFile[]; hasActiveDesignSystem?: boolean; sendDisabled?: boolean; @@ -289,6 +297,7 @@ export function ChatPane({ sendDisabled = false, error, projectId, + projectKindForTracking = null, projectFiles, hasActiveDesignSystem = false, projectFileNames, @@ -329,6 +338,7 @@ export function ChatPane({ onCollapse, }: Props) { const t = useT(); + const analytics = useAnalytics(); const logRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); @@ -650,7 +660,19 @@ export function ChatPane({ aria-label={t('chat.conversationsAria')} aria-haspopup="menu" aria-expanded={showConvList} - onClick={() => setShowConvList((v) => !v)} + onClick={() => { + setShowConvList((v) => { + const next = !v; + if (next) { + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'history', + }); + } + return next; + }); + }} > @@ -708,7 +730,15 @@ export function ChatPane({ data-testid="new-conversation" title={t('chat.newConversationsTitle')} aria-label={t('chat.newConversation')} - onClick={onNewConversation} + onClick={() => { + if (!onNewConversation || newConversationDisabled) return; + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'new_chat', + }); + onNewConversation(); + }} disabled={!onNewConversation || newConversationDisabled} > @@ -720,7 +750,14 @@ export function ChatPane({ data-testid="chat-collapse" title={t('workspace.focusMode')} aria-label={t('workspace.focusMode')} - onClick={onCollapse} + onClick={() => { + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'back', + }); + onCollapse(); + }} > @@ -746,7 +783,14 @@ export function ChatPane({ role="listitem" className="chat-example" style={{ animationDelay: `${i * 70}ms` }} - onClick={() => composerRef.current?.setDraft(ex.prompt)} + onClick={() => { + trackChatPanelClick(analytics.track, { + page_name: 'chat_panel', + area: 'chat_panel', + element: 'template_card', + }); + composerRef.current?.setDraft(ex.prompt); + }} title={t('chat.fillInputTitle')} > @@ -795,6 +839,8 @@ export function ChatPane({ message={m} streaming={messageStreaming} projectId={projectId} + projectKind={projectKindForTracking} + conversationId={activeConversationId} projectFiles={projectFiles} projectFileNames={projectFileNames} onRequestOpenFile={onRequestOpenFile} diff --git a/apps/web/src/components/ConnectorsBrowser.tsx b/apps/web/src/components/ConnectorsBrowser.tsx index 0c195f9215..b01fc2cdf1 100644 --- a/apps/web/src/components/ConnectorsBrowser.tsx +++ b/apps/web/src/components/ConnectorsBrowser.tsx @@ -402,6 +402,25 @@ function applyConnectorStatuses( interface ConnectorsBrowserProps { composioConfigured: boolean; catalogRefreshKey?: string | number; + /** Optional analytics hook for the integrations surface. The parent + * (IntegrationsView → ConnectorSection) wires this so provider-tab + * / search clicks emit on `page_name: 'integrations'`; when omitted + * (SettingsDialog uses the settings page family instead), no event + * is fired. */ + onConnectorsTabClick?: ( + element: 'provider_chip' | 'search_connectors', + ) => void; + /** Analytics hook for the per-connector authorization result. The + * daemon emits its own server-side telemetry but the click→outcome + * loop happens in the browser; this lets the parent emit + * `settings_connector_auth_result` for the completed connect / + * disconnect attempts the user kicked off here. */ + onConnectorAuthResult?: (params: { + connectorId: string; + action: 'connect' | 'disconnect' | 'refresh'; + result: 'success' | 'failed' | 'cancelled'; + errorCode?: string; + }) => void; } /** @@ -558,6 +577,8 @@ const CONNECTOR_CATEGORY_KEYS = { export function ConnectorsBrowser({ composioConfigured, catalogRefreshKey = 0, + onConnectorsTabClick, + onConnectorAuthResult, }: ConnectorsBrowserProps) { const t = useT(); const [connectors, setConnectors] = useState([]); @@ -578,6 +599,7 @@ export function ConnectorsBrowser({ const [filter, setFilter] = useState(''); const [selectedProvider, setSelectedProvider] = useState(DEFAULT_PROVIDER_TAB_ID); const searchInputRef = useRef(null); + const searchTrackedRef = useRef(false); const logoTheme = useResolvedTheme(); const toolPreviewRetryToken = `${composioConfigured ? 'configured' : 'unconfigured'}:${String(catalogRefreshKey)}`; @@ -793,18 +815,39 @@ export function ConnectorsBrowser({ delete next[connectorId]; return next; }); - const result = await connectConnector(connectorId); - updateConnector(result.connector); - if (result.connector && !result.error) { - setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromConnectResponse(curr, { - connector: result.connector!, - ...(result.auth === undefined ? {} : { auth: result.auth }), - })); - } else { - setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId)); - if (result.error) { - setConnectorAuthorizationError((curr) => ({ ...curr, [connectorId]: result.error! })); + try { + const result = await connectConnector(connectorId); + updateConnector(result.connector); + if (result.connector && !result.error) { + setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromConnectResponse(curr, { + connector: result.connector!, + ...(result.auth === undefined ? {} : { auth: result.auth }), + })); + onConnectorAuthResult?.({ + connectorId, + action: 'connect', + result: 'success', + }); + } else { + setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId)); + if (result.error) { + setConnectorAuthorizationError((curr) => ({ ...curr, [connectorId]: result.error! })); + } + onConnectorAuthResult?.({ + connectorId, + action: 'connect', + result: 'failed', + ...(result.error ? { errorCode: result.error } : {}), + }); } + } catch (err) { + onConnectorAuthResult?.({ + connectorId, + action: 'connect', + result: 'failed', + errorCode: err instanceof Error ? err.message : String(err), + }); + throw err; } } else { setConnectorAuthorizationPending((curr) => clearConnectorAuthorizationPending(curr, connectorId)); @@ -814,7 +857,22 @@ export function ConnectorsBrowser({ delete next[connectorId]; return next; }); - updateConnector(await disconnectConnector(connectorId)); + try { + updateConnector(await disconnectConnector(connectorId)); + onConnectorAuthResult?.({ + connectorId, + action: 'disconnect', + result: 'success', + }); + } catch (err) { + onConnectorAuthResult?.({ + connectorId, + action: 'disconnect', + result: 'failed', + errorCode: err instanceof Error ? err.message : String(err), + }); + throw err; + } } } finally { setPendingConnectorAction(null); @@ -929,7 +987,10 @@ export function ConnectorsBrowser({ role="tab" aria-selected={active} className={`connectors-provider-tab${active ? ' is-active' : ''}`} - onClick={() => setSelectedProvider(provider.id)} + onClick={() => { + onConnectorsTabClick?.('provider_chip'); + setSelectedProvider(provider.id); + }} data-testid={`connectors-provider-tab-${provider.id}`} > {provider.label} @@ -945,6 +1006,11 @@ export function ConnectorsBrowser({ ref={searchInputRef} type="search" value={filter} + onFocus={() => { + if (searchTrackedRef.current) return; + searchTrackedRef.current = true; + onConnectorsTabClick?.('search_connectors'); + }} onChange={(event) => setFilter(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' && filter) { diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index 0ed5ed440c..3ef56efa76 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useAnalytics } from '../analytics/provider'; +import { trackFileManagerClick } from '../analytics/events'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectFileUrl } from '../providers/registry'; @@ -78,6 +80,7 @@ export function DesignFilesPanel({ onPluginFolderAgentAction, }: Props) { const t = useT(); + const analytics = useAnalytics(); const [refreshing, setRefreshing] = useState(false); const [draggingFiles, setDraggingFiles] = useState(false); const dragDepthRef = useRef(0); @@ -717,7 +720,14 @@ export function DesignFilesPanel({
diff --git a/apps/web/src/components/PrivacySection.tsx b/apps/web/src/components/PrivacySection.tsx index 9992e47613..974025f0a8 100644 --- a/apps/web/src/components/PrivacySection.tsx +++ b/apps/web/src/components/PrivacySection.tsx @@ -1,4 +1,6 @@ import type { Dispatch, SetStateAction } from 'react'; +import { useAnalytics } from '../analytics/provider'; +import { trackSettingsPrivacyClick } from '../analytics/events'; import { useT } from '../i18n'; import { Icon } from './Icon'; import type { AppConfig, TelemetryConfig } from '../types'; @@ -19,6 +21,7 @@ function generateInstallationId(): string { export function PrivacySection({ cfg, setCfg }: Props): JSX.Element { const t = useT(); + const analytics = useAnalytics(); const telemetry: TelemetryConfig = cfg.telemetry ?? {}; // `privacyDecisionAt` gates the consent surface. installationId is only // the anonymous reporting id and can be rotated by Delete my data without @@ -79,19 +82,43 @@ export function PrivacySection({ cfg, setCfg }: Props): JSX.Element { label={t('settings.privacyMetrics')} hint={t('settings.privacyMetricsHint')} checked={telemetry.metrics === true} - onChange={(v) => patchTelemetry({ metrics: v })} + onChange={(v) => { + trackSettingsPrivacyClick(analytics.track, { + page: 'settings', + area: 'privacy', + element: 'anonymous_metrics', + anonymous_metrics_status: v ? 'on' : 'off', + }); + patchTelemetry({ metrics: v }); + }} /> patchTelemetry({ content: v })} + onChange={(v) => { + trackSettingsPrivacyClick(analytics.track, { + page: 'settings', + area: 'privacy', + element: 'conversation_and_tool_content', + conversation_and_tool_content_status: v ? 'on' : 'off', + }); + patchTelemetry({ content: v }); + }} /> patchTelemetry({ artifactManifest: v })} + onChange={(v) => { + trackSettingsPrivacyClick(analytics.track, { + page: 'settings', + area: 'privacy', + element: 'project_artifacts_manifest', + project_artifacts_manifest_status: v ? 'on' : 'off', + }); + patchTelemetry({ artifactManifest: v }); + }} /> @@ -113,7 +140,14 @@ export function PrivacySection({ cfg, setCfg }: Props): JSX.Element {