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 aff821c8a1..4d083cffa4 100644 --- a/apps/daemon/src/chat-routes.ts +++ b/apps/daemon/src/chat-routes.ts @@ -1,6 +1,5 @@ import type { Express } from 'express'; import type { RouteDeps } from './server-context.js'; -import { newInsertId } from './analytics.js'; import { seedProviderIfMissing } from './media-config.js'; import { BYOK_SENSEAUDIO_TOOLS, @@ -10,40 +9,11 @@ import { type BYOKToolContext, } from './byok-tools.js'; import { isSafeId as isSafeProjectId } from './projects.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' | 'paths'> {} -// 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; @@ -76,147 +46,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 4927ae6d12..6dd24e2428 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -186,9 +186,14 @@ import { createChatRunService } from './runs.js'; import { reportRunCompletedFromDaemon } from './langfuse-bridge.js'; import { createAnalyticsService, + newInsertId, readAnalyticsContext, readPublicConfigResponse, } from './analytics.js'; +import { + agentIdToTracking, + deriveConfigureGlobals, +} from '@open-design/contracts/analytics'; import { redactSecrets, testAgentConnection, @@ -10512,6 +10517,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'); @@ -10550,6 +10568,186 @@ 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(); + // Configure-state triplet — v2 schema requires every event to carry + // these so PostHog dashboards can split run lifecycle by execution + // setup. Web-side captures inherit them from a PostHog global + // register, but daemon-side captures (run_created/run_finished) need + // to populate them at capture time. Best-effort derivation from + // `detectAgents()` + the request's `agentId`: + // - has_available_configure_cli: any CLI on PATH appears installed + // - configure_type: 'local_cli' when the run targets an installed + // CLI, otherwise 'unknown' (BYOK keys live in the web client + // storage and are not visible to the daemon at this layer) + // - configure_availability: 'available' when the requested CLI is + // installed; 'unavailable' when it's known but not installed; + // 'unknown' otherwise + const appCfgForAnalytics = await readAppConfig(RUNTIME_DATA_DIR).catch( + () => ({} as Record), + ); + const detectedAgentsForAnalytics = await detectAgents( + (appCfgForAnalytics as { agentCliEnv?: Record }).agentCliEnv ?? {}, + ).catch(() => [] as Array<{ id: string; available: boolean }>); + // BYOK credentials live in the web client (localStorage / store) and + // are not visible to the daemon at this layer, so we pass + // `byokConfigured: undefined` and let the helper fall back to the + // installed-CLI signal. Web-side captures use the same helper with + // the full credential view to keep dashboards aligned. + // + // `mode: 'daemon'` pins the call into the helper's daemon branch so + // `configure_availability` is judged from the requested agent's + // install status (not the cohort-wide "any CLI installed?" fallback). + // Without it, a run for an uninstalled agent would still report + // `available` whenever any unrelated CLI was on PATH — see PR #2285 + // review. + const configureGlobals = deriveConfigureGlobals({ + mode: 'daemon', + agentId: typeof reqBody.agentId === 'string' ? reqBody.agentId : null, + agents: detectedAgentsForAnalytics, + }); + 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; + // Only fields the current `/api/runs` create payload actually + // sends. The v2 schema documents extended context props + // (entry_from / project_kind / target_platforms / fidelity / + // companion_surfaces / connectors / use_speaker_notes / + // include_animations / reference_template / aspect / + // project_source) but `packages/contracts/src/api/chat.ts` and + // `apps/web/src/providers/daemon.ts` do not yet thread them onto + // the wire, so reading them here would always produce null/undef + // — better to omit until a follow-up extends the create payload. + const baseProps: Record = { + page_name: 'chat_panel', + area: 'chat_composer', + ...configureGlobals, + project_id: typeof reqBody.projectId === 'string' ? reqBody.projectId : null, + conversation_id: + typeof reqBody.conversationId === 'string' ? reqBody.conversationId : null, + run_id: run.id, + design_system_id: + typeof reqBody.designSystemId === 'string' + ? reqBody.designSystemId + : undefined, + // `design_system_source` is required in the v2 contract + // (RunCreatedProps / RunFinishedProps). The daemon doesn't see + // whether the chosen design system was the workspace default, + // a user pick, or template-inherited — that signal lives only + // in the web client. Derive what we honestly know from the + // wire payload: 'not_applicable' when no design system was + // selected, 'unknown' otherwise. A follow-up that threads + // `designSystemSource` through `CreateRunRequest` can replace + // this with the precise value. See PR #2285 review 2026-05-20 + // 04:35 for the rationale. + design_system_source: + typeof reqBody.designSystemId === 'string' && reqBody.designSystemId + ? 'unknown' + : 'not_applicable', + 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 3faaaadb1a..fa5c58a56e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,8 +1,9 @@ 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 { + deriveConfigureGlobals, projectKindToTracking, fidelityToTracking, } from '@open-design/contracts/analytics'; @@ -233,22 +234,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 @@ -269,6 +259,47 @@ export function App() { analytics.setIdentity(config.installationId ?? null); }, [analytics.setIdentity, config.installationId, config.telemetry?.metrics]); + // v2 analytics requires every event to carry the configure-state + // triplet (has_available_configure_cli / configure_type / + // configure_availability). We push it into the PostHog global register + // whenever the user's execution-mode config or the detected agent list + // changes; the next capture inherits the fresh values, so dashboards + // can segment by execution setup without per-helper boilerplate. + // + // Gated on `agentsLoading` so the cold-start probe (`fetchAgents()` + // lands asynchronously after this effect's first run) does not stamp + // the first home/projects/plugins page_view with + // has_available_configure_cli=false / configure_availability=unavailable + // on machines that DO have an installed CLI. While the probe is in + // flight we leave the boot defaults ('unknown'/'unknown') in place, + // matching what the helper would return for an empty agent list with + // no mode pinned. + useEffect(() => { + if (agentsLoading) return; + const byokConfigured = (() => { + const protocols = config.apiProtocolConfigs; + if (!protocols) return Boolean(config.apiKey?.trim()); + return Object.values(protocols).some( + (cfg) => Boolean(cfg?.apiKey?.trim()), + ); + })(); + const globals = deriveConfigureGlobals({ + mode: config.mode, + agentId: config.agentId, + agents: agents.map((a) => ({ id: a.id, available: a.available })), + byokConfigured, + }); + analytics.setConfigureGlobals(globals); + }, [ + analytics.setConfigureGlobals, + agentsLoading, + config.mode, + config.agentId, + config.apiKey, + config.apiProtocolConfigs, + agents, + ]); + // Sync theme preference to the element so CSS variables pick it up. // useLayoutEffect (vs useEffect) fires before the browser paints, so a // live theme switch in Settings applies atomically — no 1-frame flash of @@ -767,12 +798,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', @@ -795,12 +825,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..60f0624539 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,67 @@ 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', +}; +// Snapshot of the super-property payload sent on the most recent `loaded()` +// init. `reset()` clears posthog-js's persisted super-properties as well as +// the distinct_id, so privacy → metrics off → on, or a Delete-my-data +// rotation (applyIdentity()), would otherwise resume capture without +// `event_schema_version`, `device_id`, `session_id`, `locale`, or the +// configure-state globals. We restash this on init and re-register it +// after every reset()/identify() so every subsequent event keeps the +// v2 schema contract. +let lastRegisterPayload: Record | null = null; // 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 }; + // Keep the cached register payload aligned so a future reset/identify + // flow that calls `restoreSuperProperties()` uses the LATEST configure + // state, not the stale snapshot captured during the initial `loaded()`. + if (lastRegisterPayload) { + lastRegisterPayload = { + ...lastRegisterPayload, + ...(configureGlobals as unknown as Record), + }; + } + 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 +106,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 +117,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, @@ -104,15 +166,19 @@ export async function getAnalyticsClient( disable_session_recording: true, loaded: (instance) => { - instance.register({ + lastRegisterPayload = { event_schema_version: EVENT_SCHEMA_VERSION, ui_version: context.appVersion, app_version: context.appVersion, 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), + }; + instance.register(lastRegisterPayload); }, }); client = posthog; @@ -153,10 +219,18 @@ export function applyConsent(consentGranted: boolean): void { try { if (consentGranted) { client.opt_in_capturing(); + // If the user previously toggled metrics off in this session, the + // earlier opt-out path called reset() and wiped the persisted + // super-properties. opt_in_capturing() only flips the consent flag + // and does not re-run init(), so without this restore the next + // capture would emit no event_schema_version / device_id / + // session_id / locale / configure-state. See PR #2285 review + // 2026-05-20 04:35. + restoreSuperProperties(); } else { client.opt_out_capturing(); client.reset(); - resolvedAnonymousId = null; + resolvedDeviceId = null; } } catch { // best-effort — capture should never throw out of this path. @@ -171,16 +245,36 @@ 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; + // reset() also clears the persisted super-properties from + // posthog-js's localStorage cache. Re-register them with the new + // distinct_id so the rest of this session keeps emitting v2-schema + // events. See PR #2285 review 2026-05-20 04:35. + restoreSuperProperties({ device_id: installationId }); } catch { // best-effort — never propagate. } } +// Push the cached super-property payload back onto the PostHog client. Used +// after reset()/identify() flows; takes an optional override patch so the +// caller can swap fields (e.g. a rotated device_id) without re-deriving the +// rest of the payload. +function restoreSuperProperties(patch?: Record): void { + if (!client || !lastRegisterPayload) return; + const next = patch ? { ...lastRegisterPayload, ...patch } : lastRegisterPayload; + lastRegisterPayload = next; + try { + client.register(next); + } catch { + // best-effort. + } +} + export function capture( client: PostHog | null, args: { diff --git a/apps/web/src/analytics/events.ts b/apps/web/src/analytics/events.ts index f43503f137..c91e82c729 100644 --- a/apps/web/src/analytics/events.ts +++ b/apps/web/src/analytics/events.ts @@ -1,177 +1,618 @@ -// 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, + props: PluginReplacementModalSurfaceViewProps, +): void { + send(track, 'surface_view', props); +} + +export function trackDesignSystemsTemplatesModalSurfaceView( + track: Track, + props: DesignSystemsTemplatesModalSurfaceViewProps, +): void { + send(track, 'surface_view', props); +} + +export function trackAssistantFeedbackReasonPanelSurfaceView( + track: Track, + props: AssistantFeedbackReasonPanelSurfaceViewProps, +): void { + send(track, 'surface_view', props); +} + +// ---- ui_click (home) ----------------------------------------------------- + +export function trackHomeNavClick( + track: Track, + props: HomeNavClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackHelpPopoverClick( + track: Track, + props: HelpPopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackHomeToolbarClick( + track: Track, + props: HomeToolbarClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackExecutionSettingsPopoverClick( + track: Track, + props: ExecutionSettingsPopoverClickProps, +): void { + send(track, 'ui_click', props); +} + +export function trackSettingsPopoverClick( + track: Track, + 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, options?: { requestId?: string }, -) { - track( - 'project_create_result', - props as unknown as Record, - options, - ); +): void { + send(track, 'ui_click', props, options); } -export function trackSettingsView(track: Track, props: SettingsViewProps) { - track('settings_view', props as unknown as Record); +export function trackPluginReplacementModalClick( + track: Track, + props: PluginReplacementModalClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickExecutionModeTab( +export function trackPrivacyModalClick( track: Track, - props: SettingsClickExecutionModeTabProps, -) { - track('settings_click', props as unknown as Record); + props: PrivacyModalClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickCliProviderCard( +export function trackRecentProjectsClick( track: Track, - props: SettingsClickCliProviderCardProps, -) { - track('settings_click', props as unknown as Record); + props: RecentProjectsClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickByokField( +export function trackHomeTemplatesClick( track: Track, - props: SettingsClickByokFieldProps, -) { - track('settings_click', props as unknown as Record); + props: HomeTemplatesClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsClickByokProviderOption( +export function trackHomeTemplatesDropdownClick( track: Track, - props: SettingsClickByokProviderOptionProps, -) { - track('settings_click', props as unknown as Record); + props: HomeTemplatesDropdownClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsCliTestResult( +// ---- ui_click (projects) ------------------------------------------------- + +export function trackProjectsListControlsClick( track: Track, - props: SettingsCliTestResultProps, -) { - track( - 'settings_cli_test_result', - props as unknown as Record, - ); + props: ProjectsListControlsClickProps, +): void { + send(track, 'ui_click', props); } -export function trackSettingsByokTestResult( +export function trackProjectsListClick( track: Track, - props: SettingsByokTestResultProps, -) { - track( - 'settings_byok_test_result', - props as unknown as Record, - ); + 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, options?: { requestId?: string }, -) { - track( - 'artifact_export_result', - props as unknown as Record, - options, - ); +): void { + send(track, 'ui_click', props, options); +} + +// ---- 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 }, +): 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..ece2c0d030 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, @@ -24,7 +24,9 @@ import { capture, getAnalyticsClient, getResolvedAnonymousId, + setConfigureGlobals, } from './client'; +import type { AnalyticsConfigureGlobals } from '@open-design/contracts/analytics'; import { detectClientType, getAnonymousId, @@ -50,6 +52,12 @@ interface AnalyticsContextValue { // state is reset() then identify()'d to the new id so the next event // batch is fully decoupled from the deleted identity. setIdentity: (installationId: string | null) => void; + // Push the configure-state triplet (has_available_configure_cli / + // configure_type / configure_availability) to the PostHog global + // register so every subsequent capture inherits it. Called from + // App.tsx whenever the user's execution-mode config changes (mode + // switch, agent select, BYOK save, CLI rescan). + setConfigureGlobals: (next: AnalyticsConfigureGlobals) => void; anonymousId: string; sessionId: string; newRequestId: () => string; @@ -157,7 +165,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, }; @@ -282,6 +290,9 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) { // start using the new id immediately, not after the next reload. if (installationId) setResolvedAnonId(installationId); }, + setConfigureGlobals: (next: AnalyticsConfigureGlobals) => { + setConfigureGlobals(next); + }, anonymousId: identity.anonymousId, sessionId: identity.sessionId, newRequestId: () => randomUUID(), @@ -302,6 +313,7 @@ export function useAnalytics(): AnalyticsContextValue { track: () => undefined, setConsent: () => undefined, setIdentity: () => undefined, + setConfigureGlobals: () => undefined, anonymousId: 'unmounted', sessionId: 'unmounted', newRequestId: () => randomUUID(), diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 35ab885305..d55c52c501 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,61 @@ 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 } : {}), + }, + { requestId }, + ); + // 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 e04bbd116a..3fcadad91c 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -11,8 +11,7 @@ import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { useAnalytics } from '../analytics/provider'; import { - trackStudioClickChatComposer, - trackStudioViewChatPanel, + trackChatPanelClick, } from '../analytics/events'; import { IMAGE_MODELS } from "../media/models"; import { projectRawUrl, uploadProjectFiles, openFolderDialog, fetchConnectors } from "../providers/registry"; @@ -210,23 +209,10 @@ export const ChatComposer = forwardRef( const analytics = useAnalytics(); const [draft, setDraft] = useState(initialDraft ?? ""); - // studio_view chat_panel — fire once per ChatComposer mount per project. - // The composer is the dominant chat surface; firing here keeps the - // event close to where the user actually sees the panel rather than at - // the higher-level ProjectView layer which mounts before the composer. - const studioViewFiredRef = useRef(null); - 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, - }); - }, [projectId, analytics.track]); + // chat_panel page_view fires from ProjectView (which outlives + // conversation switches) so the event measures real chat-panel + // entries rather than ChatComposer remounts. See PR #2285 review + // 2026-05-20 04:08 for the rationale. const [staged, setStaged] = useState([]); const [stagedVisualComments, setStagedVisualComments] = useState([]); // Skills the user has @-mentioned for this turn. We dedupe on id and @@ -1400,7 +1386,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} @@ -1553,6 +1555,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?.(); }} @@ -1568,13 +1575,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(); }} @@ -1604,14 +1608,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 7c82e90d8d..ea38dd38de 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; @@ -295,6 +303,7 @@ export function ChatPane({ sendDisabled = false, error, projectId, + projectKindForTracking = null, projectFiles, hasActiveDesignSystem = false, projectFileNames, @@ -338,6 +347,7 @@ export function ChatPane({ onChangeByokImageModel, }: Props) { const t = useT(); + const analytics = useAnalytics(); const logRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); @@ -659,7 +669,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; + }); + }} > @@ -717,7 +739,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} > @@ -729,7 +759,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(); + }} > @@ -755,7 +792,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')} > @@ -804,6 +848,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 d64259fdc0..424e74e26f 100644 --- a/apps/web/src/components/ConnectorsBrowser.tsx +++ b/apps/web/src/components/ConnectorsBrowser.tsx @@ -251,6 +251,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; } /** @@ -407,6 +426,8 @@ const CONNECTOR_CATEGORY_KEYS = { export function ConnectorsBrowser({ composioConfigured, catalogRefreshKey = 0, + onConnectorsTabClick, + onConnectorAuthResult, }: ConnectorsBrowserProps) { const t = useT(); const [connectors, setConnectors] = useState([]); @@ -427,6 +448,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)}`; @@ -642,18 +664,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)); @@ -663,7 +706,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); @@ -778,7 +836,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} @@ -794,6 +855,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 d8bb65c201..b719f9b6fb 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'; @@ -90,6 +92,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); @@ -729,7 +732,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 {