From 120cee4498ec49f264f986ece8fd61babbcc97ff Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Thu, 2 Jul 2026 23:20:47 +0530 Subject: [PATCH] feat(telemetry): instrument renderer failure and CTA events Add PostHog instrumentation for app failures and user CTAs: - daemon_failure (machine-readable code on DaemonStatus, IPC-forwarded) - api_error central interceptor (categorized, IDs stripped) - terminal_attach_failed (pane error + open timeout) - CTA triads: task_create, session_kill, settings_save, orchestrator_spawn (board/restore_dialog), notification open/read All events sanitized: project_id hashed, enum-only codes, raw error messages never sent. Co-Authored-By: Claude Opus 4.7 --- frontend/src/main.ts | 9 +- .../src/renderer/components/NewTaskDialog.tsx | 4 + .../components/NotificationCenter.tsx | 5 + .../components/ProjectSettingsForm.tsx | 6 + .../components/RestoreUnavailableDialog.tsx | 13 ++ .../src/renderer/components/SessionsBoard.tsx | 6 + .../src/renderer/components/ShellTopbar.tsx | 7 +- .../src/renderer/hooks/useTerminalSession.ts | 7 + frontend/src/renderer/lib/api-client.test.ts | 123 +++++++++++++++++- frontend/src/renderer/lib/api-client.ts | 112 ++++++++++++---- .../src/renderer/lib/daemon-telemetry.test.ts | 119 +++++++++++++++++ frontend/src/renderer/lib/daemon-telemetry.ts | 53 ++++++++ frontend/src/renderer/lib/telemetry.test.ts | 80 ++++++++++++ frontend/src/renderer/lib/telemetry.ts | 43 +++++- frontend/src/renderer/main.tsx | 2 + frontend/src/shared/daemon-attach.test.ts | 2 + frontend/src/shared/daemon-attach.ts | 2 + frontend/src/shared/daemon-status.ts | 16 +++ 18 files changed, 577 insertions(+), 32 deletions(-) create mode 100644 frontend/src/renderer/lib/daemon-telemetry.test.ts create mode 100644 frontend/src/renderer/lib/daemon-telemetry.ts diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c408e41c91..bafeb267d9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -478,6 +478,7 @@ async function refreshDaemonStatus(): Promise { setDaemonStatus({ state: "stopped", message: "AO daemon is no longer reachable.", + code: "daemon_unreachable", }); } return daemonStatus; @@ -518,6 +519,7 @@ async function startDaemonInner(startEpoch: number): Promise { setDaemonStatus({ state: "stopped", message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.", + code: "not_configured", }); return daemonStatus; } @@ -640,6 +642,7 @@ async function startDaemonInner(startEpoch: number): Promise { setDaemonStatus({ state: "error", message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`, + code: "binary_missing", }); return daemonStatus; } @@ -737,6 +740,7 @@ async function startDaemonInner(startEpoch: number): Promise { state: "ready", port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined, message: "Daemon port not confirmed from logs or running.json; assuming the configured port.", + code: "port_unconfirmed", }); }, PORT_DISCOVERY_TIMEOUT_MS); @@ -745,7 +749,7 @@ async function startDaemonInner(startEpoch: number): Promise { if (daemonProcess !== child) return; daemonProcess = null; if (daemonStoppingProcess === child) daemonStoppingProcess = null; - setDaemonStatus({ state: "error", message: error.message }); + setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" }); }); child.once("exit", (code, signal) => { @@ -756,6 +760,9 @@ async function startDaemonInner(startEpoch: number): Promise { setDaemonStatus({ state: "stopped", message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`, + code: "exited", + exitCode: code, + signal, }); }); diff --git a/frontend/src/renderer/components/NewTaskDialog.tsx b/frontend/src/renderer/components/NewTaskDialog.tsx index cef37d5b8e..973b273486 100644 --- a/frontend/src/renderer/components/NewTaskDialog.tsx +++ b/frontend/src/renderer/components/NewTaskDialog.tsx @@ -7,6 +7,7 @@ import { Input } from "./ui/input"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { captureRendererEvent } from "../lib/telemetry"; import type { AgentProvider } from "../types/workspace"; type Project = components["schemas"]["Project"]; @@ -77,6 +78,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT setIsSubmitting(true); setError(undefined); + void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId }); try { const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", { body: { @@ -90,9 +92,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT }); if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task")); if (!data?.session?.id) throw new Error("Task creation returned no session"); + void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId }); onCreated(data.session.id); onOpenChange(false); } catch (err) { + void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { setIsSubmitting(false); diff --git a/frontend/src/renderer/components/NotificationCenter.tsx b/frontend/src/renderer/components/NotificationCenter.tsx index 5822052616..02da22b80f 100644 --- a/frontend/src/renderer/components/NotificationCenter.tsx +++ b/frontend/src/renderer/components/NotificationCenter.tsx @@ -10,6 +10,7 @@ import { import { aoBridge } from "../lib/bridge"; import { formatTimeCompact } from "../lib/format-time"; import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications"; +import { captureRendererEvent } from "../lib/telemetry"; import { cn } from "../lib/utils"; import { DropdownMenu, @@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) { (notification: NotificationDTO) => { const target = notification.target; if (target.kind === "pr" && target.prUrl) { + void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" }); window.open(target.prUrl, "_blank", "noopener,noreferrer"); return; } const sessionId = target.sessionId || notification.sessionId; if (!sessionId) return; + void captureRendererEvent("ao.renderer.notification_opened", { target: "session" }); if (notification.projectId) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) { const markOneRead = async (id: string) => { setActionError(null); + void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" }); try { await markRead.mutateAsync(id); } catch (error) { @@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) { const markAll = async () => { setActionError(null); + void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" }); try { await markAllRead.mutateAsync(); } catch (error) { diff --git a/frontend/src/renderer/components/ProjectSettingsForm.tsx b/frontend/src/renderer/components/ProjectSettingsForm.tsx index c6be21a19d..88badd8446 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { captureRendererEvent } from "../lib/telemetry"; import { DEFAULT_PROJECT_AGENT } from "../lib/agent-options"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; @@ -80,6 +81,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje const mutation = useMutation({ mutationFn: async () => { + void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId }); // PUT replaces the whole config; merge the edited fields over what loaded // so we don't drop env/symlinks/postCreate the form doesn't expose. const next: ProjectConfig = { @@ -102,10 +104,14 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje if (error) throw new Error(apiErrorMessage(error)); }, onSuccess: () => { + void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId }); setSavedAt(Date.now()); void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); onSaved(); }, + onError: () => { + void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId }); + }, }); return ( diff --git a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx index d5f99d338b..9fe0edb76d 100644 --- a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx +++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx @@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react"; import { useState } from "react"; import { Button } from "./ui/button"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { captureRendererEvent } from "../lib/telemetry"; import { isOrchestratorSession } from "../types/workspace"; import type { WorkspaceSession } from "../types/workspace"; @@ -21,11 +22,23 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea const recreate = async () => { setBusy(true); setError(undefined); + void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { + project_id: session.workspaceId, + source: "restore_dialog", + }); try { const id = await spawnOrchestrator(session.workspaceId, true); + void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { + project_id: session.workspaceId, + source: "restore_dialog", + }); onOpenChange(false); onRecreated(id); } catch (err) { + void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { + project_id: session.workspaceId, + source: "restore_dialog", + }); setError(err instanceof Error ? err.message : "Failed to create orchestrator"); } finally { setBusy(false); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 7da1b8a303..d5f2b40220 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -9,6 +9,7 @@ import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { captureRendererEvent } from "../lib/telemetry"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; @@ -103,13 +104,18 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { return; } setIsSpawning(true); + void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source: "board" }); try { const sessionId = await spawnOrchestrator(projectId); + void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source: "board" }); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId }, }); + } catch (error) { + void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source: "board" }); + throw error; } finally { setIsSpawning(false); } diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index c336b19cb3..43b8ac2fe5 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -249,16 +249,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) { const kill = useMutation({ mutationFn: async () => { + void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId }); const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", { params: { path: { sessionId: session.id } }, }); if (apiError) throw new Error(apiErrorMessage(apiError)); }, onSuccess: () => { + void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId }); setConfirming(false); void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); }, - onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"), + onError: (e) => { + void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId }); + setError(e instanceof Error ? e.message : "Kill failed"); + }, }); if (confirming) { diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index ae36c03d35..5db55ec4d4 100644 --- a/frontend/src/renderer/hooks/useTerminalSession.ts +++ b/frontend/src/renderer/hooks/useTerminalSession.ts @@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { getApiBaseUrl } from "../lib/api-client"; +import { captureRendererEvent } from "../lib/telemetry"; import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux"; import type { WorkspaceSession } from "../types/workspace"; import { workspaceQueryKey } from "./useWorkspaceQuery"; @@ -212,6 +213,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`); setError(message); transition("error"); + void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" }); invalidateWorkspaces(); }), mux.onConnectionChange((connectionState) => { @@ -267,6 +269,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option r.openTimer = setTimeout(() => { if (!isCurrentAttachment(generation, handle, mux)) return; r.openTimer = null; + // Only the first timeout of a reattach sequence is reported; the + // backoff loop retrying against a restarting daemon is not news. + if (r.attempts === 0) { + void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" }); + } transition("reattaching"); teardownMux(); scheduleReattach(); diff --git a/frontend/src/renderer/lib/api-client.test.ts b/frontend/src/renderer/lib/api-client.test.ts index f72096dd88..e2a94f93b3 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,5 +1,19 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiClient, getApiBaseUrl, hasTrustedApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + apiClient, + getApiBaseUrl, + hasTrustedApiBaseUrl, + normalizeApiOperation, + setApiBaseUrl, + subscribeApiBaseUrl, +} from "./api-client"; +import { captureRendererEvent } from "./telemetry"; + +vi.mock("./telemetry", () => ({ + captureRendererEvent: vi.fn().mockResolvedValue(undefined), +})); + +const captureMock = vi.mocked(captureRendererEvent); describe("apiClient runtime base URL", () => { afterEach(() => { @@ -154,3 +168,108 @@ describe("subscribeApiBaseUrl", () => { expect(listener).not.toHaveBeenCalled(); }); }); + +describe("normalizeApiOperation", () => { + it("replaces identifier segments after resource collections", () => { + expect(normalizeApiOperation("get", "/api/v1/projects/my project id")).toBe("GET /api/v1/projects/:id"); + expect(normalizeApiOperation("POST", "/api/v1/sessions/ao-42/kill")).toBe("POST /api/v1/sessions/:id/kill"); + expect(normalizeApiOperation("PUT", "/api/v1/projects/p1/config")).toBe("PUT /api/v1/projects/:id/config"); + }); + + it("leaves collection and non-resource paths untouched", () => { + expect(normalizeApiOperation("GET", "/api/v1/projects")).toBe("GET /api/v1/projects"); + expect(normalizeApiOperation("POST", "/api/v1/orchestrators")).toBe("POST /api/v1/orchestrators"); + }); +}); + +describe("api error telemetry", () => { + // The dedupe window keys off Date.now(); jump the clock far past any + // earlier test's reports so each test starts with a clean window. + let clock = Date.UTC(2100, 0, 1); + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + clock += 10 * 60_000; + vi.setSystemTime(clock); + captureMock.mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + setApiBaseUrl("http://127.0.0.1:3001"); + }); + + it("reports http_5xx with a normalized operation", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 })); + setApiBaseUrl("http://127.0.0.1:3037"); + + await apiClient.GET("/api/v1/projects"); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", { + operation: "GET /api/v1/projects", + error_category: "http_5xx", + status: 500, + }); + }); + + it("reports http_4xx with ids stripped from the operation", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 404 })); + setApiBaseUrl("http://127.0.0.1:3037"); + + await apiClient.POST("/api/v1/sessions/{sessionId}/kill", { + params: { path: { sessionId: "ao-raw-id" } }, + }); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", { + operation: "POST /api/v1/sessions/:id/kill", + error_category: "http_4xx", + status: 404, + }); + }); + + it("reports network_error and rethrows", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); + setApiBaseUrl("http://127.0.0.1:3037"); + + await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Failed to fetch"); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", { + operation: "GET /api/v1/projects", + error_category: "network_error", + status: undefined, + }); + }); + + it("does not report caller-initiated aborts", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("Aborted", "AbortError")); + setApiBaseUrl("http://127.0.0.1:3037"); + + await expect(apiClient.GET("/api/v1/projects")).rejects.toThrow("Aborted"); + + expect(captureMock).not.toHaveBeenCalled(); + }); + + it("reports daemon_unavailable when the base URL is untrusted", async () => { + setApiBaseUrl(null); + + await apiClient.GET("/api/v1/projects"); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.api_error", { + operation: "GET /api/v1/projects", + error_category: "daemon_unavailable", + status: 503, + }); + }); + + it("dedupes repeated identical failures within the 30s window", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("oops", { status: 502 })); + setApiBaseUrl("http://127.0.0.1:3037"); + + await apiClient.GET("/api/v1/projects"); + await apiClient.GET("/api/v1/projects"); + expect(captureMock).toHaveBeenCalledTimes(1); + + vi.setSystemTime(clock + 31_000); + await apiClient.GET("/api/v1/projects"); + expect(captureMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 9afdf193d9..d9c8ebb47b 100644 --- a/frontend/src/renderer/lib/api-client.ts +++ b/frontend/src/renderer/lib/api-client.ts @@ -1,5 +1,6 @@ import createClient from "openapi-fetch"; import type { paths } from "../../api/schema"; +import { captureRendererEvent } from "./telemetry"; function devApiBaseUrl(): string { return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin; @@ -39,43 +40,100 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void { baseUrlListeners.forEach((listener) => listener()); } +// Resource collections whose next path segment is an identifier. Operation +// strings sent to telemetry must never contain raw IDs (project IDs are +// user-chosen strings), so normalize before reporting. +const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs"]); + +export function normalizeApiOperation(method: string, pathname: string): string { + const segments = pathname.split("/"); + for (let i = 0; i < segments.length - 1; i += 1) { + if (RESOURCE_SEGMENTS.has(segments[i]) && segments[i + 1]) { + segments[i + 1] = ":id"; + i += 1; + } + } + return `${method.toUpperCase()} ${segments.join("/")}`; +} + +type ApiErrorCategory = "daemon_unavailable" | "network_error" | "http_4xx" | "http_5xx"; + +// One event per (operation, category, status) per window: a daemon outage +// makes every polling query fail at once and on every retry — the failure +// signal matters, the storm does not. +const API_ERROR_DEDUPE_MS = 30_000; +const lastApiErrorAt = new Map(); + +function reportApiError(operation: string, category: ApiErrorCategory, status?: number): void { + const key = `${operation}|${category}|${status ?? ""}`; + const now = Date.now(); + const last = lastApiErrorAt.get(key); + if (last !== undefined && now - last < API_ERROR_DEDUPE_MS) return; + lastApiErrorAt.set(key, now); + void captureRendererEvent("ao.renderer.api_error", { + operation, + error_category: category, + status, + }); +} + async function runtimeFetch(input: Request): Promise { + const operation = normalizeApiOperation(input.method, new URL(input.url).pathname); const baseUrl = runtimeApiBaseUrl; if (baseUrl === null) { + reportApiError(operation, "daemon_unavailable", 503); return new Response(JSON.stringify({ message: "AO daemon is not ready." }), { status: 503, headers: { "Content-Type": "application/json" }, }); } - if (!baseUrl) { - return fetch(input); - } - const url = new URL(input.url); - const target = new URL(url.pathname + url.search + url.hash, baseUrl); - if (target.href === input.url) { - return fetch(input); - } + const send = async (): Promise => { + if (!baseUrl) { + return fetch(input); + } - // Rebase onto the runtime base URL by copying fields explicitly and - // buffering the body. `new Request(target, input)` reads the source - // request's `duplex` getter, which Electron's Chromium lacks — it throws - // "The duplex member must be specified" for any request with a body, so - // every POST would fail in the packaged app. API bodies are small JSON; - // buffering sidesteps streaming-duplex semantics entirely. - const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer(); - return fetch(target, { - method: input.method, - headers: input.headers, - body, - signal: input.signal, - credentials: input.credentials, - cache: input.cache, - redirect: input.redirect, - referrerPolicy: input.referrerPolicy, - integrity: input.integrity, - keepalive: input.keepalive, - }); + const url = new URL(input.url); + const target = new URL(url.pathname + url.search + url.hash, baseUrl); + if (target.href === input.url) { + return fetch(input); + } + + // Rebase onto the runtime base URL by copying fields explicitly and + // buffering the body. `new Request(target, input)` reads the source + // request's `duplex` getter, which Electron's Chromium lacks — it throws + // "The duplex member must be specified" for any request with a body, so + // every POST would fail in the packaged app. API bodies are small JSON; + // buffering sidesteps streaming-duplex semantics entirely. + const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer(); + return fetch(target, { + method: input.method, + headers: input.headers, + body, + signal: input.signal, + credentials: input.credentials, + cache: input.cache, + redirect: input.redirect, + referrerPolicy: input.referrerPolicy, + integrity: input.integrity, + keepalive: input.keepalive, + }); + }; + + let response: Response; + try { + response = await send(); + } catch (error) { + // Caller-initiated aborts (unmounted components cancelling queries) are not failures. + if (!(error instanceof DOMException && error.name === "AbortError")) { + reportApiError(operation, "network_error"); + } + throw error; + } + if (!response.ok) { + reportApiError(operation, response.status >= 500 ? "http_5xx" : "http_4xx", response.status); + } + return response; } export const apiClient = createClient({ diff --git a/frontend/src/renderer/lib/daemon-telemetry.test.ts b/frontend/src/renderer/lib/daemon-telemetry.test.ts new file mode 100644 index 0000000000..718f120cd1 --- /dev/null +++ b/frontend/src/renderer/lib/daemon-telemetry.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DaemonStatus } from "../../shared/daemon-status"; +import { aoBridge } from "./bridge"; +import { startDaemonFailureTelemetry } from "./daemon-telemetry"; +import { captureRendererEvent } from "./telemetry"; + +vi.mock("./telemetry", () => ({ + captureRendererEvent: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./bridge", () => ({ + aoBridge: { + daemon: { + getStatus: vi.fn(), + onStatus: vi.fn(), + }, + }, +})); + +const captureMock = vi.mocked(captureRendererEvent); +const getStatusMock = vi.mocked(aoBridge.daemon.getStatus); +const onStatusMock = vi.mocked(aoBridge.daemon.onStatus); + +describe("daemon failure telemetry", () => { + let pushStatus!: (status: DaemonStatus) => void; + let stop: () => void = () => undefined; + + beforeEach(() => { + captureMock.mockClear(); + onStatusMock.mockClear(); + getStatusMock.mockResolvedValue({ state: "starting" }); + onStatusMock.mockImplementation((listener) => { + pushStatus = listener; + return () => undefined; + }); + }); + + afterEach(() => { + stop(); + }); + + it("reports a failing status with coarse fields only", () => { + stop = startDaemonFailureTelemetry(); + + pushStatus({ state: "error", message: "spawn /Users/alice/ao failed", code: "spawn_failed" }); + + expect(captureMock).toHaveBeenCalledTimes(1); + expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", { + daemon_state: "error", + code: "spawn_failed", + exit_code: undefined, + signal: undefined, + }); + }); + + it("includes exit code and signal for daemon exits", () => { + stop = startDaemonFailureTelemetry(); + + pushStatus({ state: "stopped", code: "exited", exitCode: 1, signal: "SIGKILL" }); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", { + daemon_state: "stopped", + code: "exited", + exit_code: 1, + signal: "SIGKILL", + }); + }); + + it("ignores statuses without a failure code (healthy or user-initiated stop)", () => { + stop = startDaemonFailureTelemetry(); + + pushStatus({ state: "ready", port: 3037 }); + pushStatus({ state: "stopped" }); + + expect(captureMock).not.toHaveBeenCalled(); + }); + + it("dedupes identical consecutive failures and resets on recovery", () => { + stop = startDaemonFailureTelemetry(); + + pushStatus({ state: "error", code: "daemon_unreachable" }); + pushStatus({ state: "error", code: "daemon_unreachable" }); + expect(captureMock).toHaveBeenCalledTimes(1); + + // A different failure is a new event. + pushStatus({ state: "stopped", code: "exited", exitCode: 137 }); + expect(captureMock).toHaveBeenCalledTimes(2); + + // Recovery resets dedupe: the same failure recurring is reported again. + pushStatus({ state: "ready", port: 3037 }); + pushStatus({ state: "error", code: "daemon_unreachable" }); + expect(captureMock).toHaveBeenCalledTimes(3); + }); + + it("reports the initial status from getStatus on start", async () => { + getStatusMock.mockResolvedValue({ state: "error", code: "binary_missing" }); + + stop = startDaemonFailureTelemetry(); + await vi.waitFor(() => expect(captureMock).toHaveBeenCalledTimes(1)); + + expect(captureMock).toHaveBeenCalledWith("ao.renderer.daemon_failure", { + daemon_state: "error", + code: "binary_missing", + exit_code: undefined, + signal: undefined, + }); + }); + + it("is idempotent while started and restartable after stop", () => { + stop = startDaemonFailureTelemetry(); + const noop = startDaemonFailureTelemetry(); + expect(onStatusMock).toHaveBeenCalledTimes(1); + noop(); + + stop(); + stop = startDaemonFailureTelemetry(); + expect(onStatusMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/renderer/lib/daemon-telemetry.ts b/frontend/src/renderer/lib/daemon-telemetry.ts new file mode 100644 index 0000000000..a029872273 --- /dev/null +++ b/frontend/src/renderer/lib/daemon-telemetry.ts @@ -0,0 +1,53 @@ +// Daemon failures happen in the Electron main process, which has no PostHog +// client. Main stamps a machine-readable `code` on every failing DaemonStatus +// (shared/daemon-status.ts); this module rides the existing daemon:status IPC +// push and reports those failures through the renderer's telemetry client. +import type { DaemonStatus } from "../../shared/daemon-status"; +import { aoBridge } from "./bridge"; +import { captureRendererEvent } from "./telemetry"; + +let started = false; +let lastFailureKey: string | null = null; + +function failureKey(status: DaemonStatus): string | null { + if (!status.code) return null; + return [status.state, status.code, status.exitCode ?? "", status.signal ?? ""].join("|"); +} + +function reportStatus(status: DaemonStatus): void { + const key = failureKey(status); + if (!key) { + // Healthy status resets dedupe so a repeat failure after recovery is a new event. + lastFailureKey = null; + return; + } + if (key === lastFailureKey) return; + lastFailureKey = key; + void captureRendererEvent("ao.renderer.daemon_failure", { + daemon_state: status.state, + code: status.code, + exit_code: typeof status.exitCode === "number" ? status.exitCode : undefined, + signal: status.signal ?? undefined, + }); +} + +/** Idempotent; returns a stop function (used by tests). */ +export function startDaemonFailureTelemetry(): () => void { + if (started) return () => undefined; + started = true; + void aoBridge.daemon + .getStatus() + .then(reportStatus) + .catch(() => undefined); + let stopListener: () => void = () => undefined; + try { + stopListener = aoBridge.daemon.onStatus(reportStatus); + } catch { + // Preload bridge unavailable (browser preview): initial getStatus already handled. + } + return () => { + started = false; + lastFailureKey = null; + stopListener(); + }; +} diff --git a/frontend/src/renderer/lib/telemetry.test.ts b/frontend/src/renderer/lib/telemetry.test.ts index 34e70d9e35..5bcf26b304 100644 --- a/frontend/src/renderer/lib/telemetry.test.ts +++ b/frontend/src/renderer/lib/telemetry.test.ts @@ -111,4 +111,84 @@ describe("telemetry sanitizers", () => { "https://api.example.com/endpoint", ); }); + + it("hashes project ids and drops everything else on CTA triads", async () => { + const triads = [ + "ao.renderer.task_create_requested", + "ao.renderer.task_create_succeeded", + "ao.renderer.task_create_failed", + "ao.renderer.session_kill_requested", + "ao.renderer.session_kill_succeeded", + "ao.renderer.session_kill_failed", + "ao.renderer.settings_save_requested", + "ao.renderer.settings_save_succeeded", + "ao.renderer.settings_save_failed", + ]; + for (const event of triads) { + const props = await sanitizeRendererProperties(event, { + project_id: "demo-project", + title: "raw user text", + error: "raw error with /Users/alice/path", + }); + expect(Object.keys(props)).toEqual(["project_id_hash"]); + } + }); + + it("keeps only the source enum on orchestrator_spawn events", async () => { + const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_requested", { + project_id: "demo-project", + source: "board", + }); + expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]); + expect(props.source).toBe("board"); + + const badSource = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_failed", { + project_id: "demo-project", + source: "/Users/alice/private", + }); + expect(badSource).not.toHaveProperty("source"); + }); + + it("keeps only enum values on notification events", async () => { + expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "pr" })).toEqual({ + target: "pr", + }); + expect(await sanitizeRendererProperties("ao.renderer.notification_opened", { target: "http://x" })).toEqual({}); + expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "all" })).toEqual({ + scope: "all", + }); + expect(await sanitizeRendererProperties("ao.renderer.notification_marked_read", { scope: "everything" })).toEqual( + {}, + ); + }); + + it("whitelists coarse daemon failure fields and drops messages", async () => { + const props = await sanitizeRendererProperties("ao.renderer.daemon_failure", { + daemon_state: "error", + code: "spawn_failed", + exit_code: 1, + signal: "SIGKILL", + message: "spawn /Users/alice/ao failed", + }); + expect(props).toEqual({ daemon_state: "error", code: "spawn_failed", exit_code: 1, signal: "SIGKILL" }); + }); + + it("whitelists normalized api_error fields", async () => { + const props = await sanitizeRendererProperties("ao.renderer.api_error", { + operation: "GET /api/v1/projects/:id", + error_category: "http_5xx", + status: 500, + body: "raw response body", + }); + expect(props).toEqual({ operation: "GET /api/v1/projects/:id", error_category: "http_5xx", status: 500 }); + }); + + it("keeps only the reason enum on terminal_attach_failed", async () => { + expect(await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "open_timeout" })).toEqual({ + reason: "open_timeout", + }); + expect( + await sanitizeRendererProperties("ao.renderer.terminal_attach_failed", { reason: "something else" }), + ).toEqual({}); + }); }); diff --git a/frontend/src/renderer/lib/telemetry.ts b/frontend/src/renderer/lib/telemetry.ts index 100c290b55..f730616afd 100644 --- a/frontend/src/renderer/lib/telemetry.ts +++ b/frontend/src/renderer/lib/telemetry.ts @@ -147,11 +147,52 @@ export async function sanitizeRendererProperties( break; case "ao.renderer.project_add_succeeded": case "ao.renderer.project_removed": - case "ao.renderer.orchestrator_open_requested": { + case "ao.renderer.orchestrator_open_requested": + case "ao.renderer.task_create_requested": + case "ao.renderer.task_create_succeeded": + case "ao.renderer.task_create_failed": + case "ao.renderer.session_kill_requested": + case "ao.renderer.session_kill_succeeded": + case "ao.renderer.session_kill_failed": + case "ao.renderer.settings_save_requested": + case "ao.renderer.settings_save_succeeded": + case "ao.renderer.settings_save_failed": { const projectIDHash = await hashedTelemetryID(properties?.project_id); if (projectIDHash) safe.project_id_hash = projectIDHash; break; } + case "ao.renderer.orchestrator_spawn_requested": + case "ao.renderer.orchestrator_spawn_succeeded": + case "ao.renderer.orchestrator_spawn_failed": { + const projectIDHash = await hashedTelemetryID(properties?.project_id); + if (projectIDHash) safe.project_id_hash = projectIDHash; + if (properties?.source === "board" || properties?.source === "restore_dialog") { + safe.source = properties.source; + } + break; + } + case "ao.renderer.notification_opened": + if (properties?.target === "pr" || properties?.target === "session") safe.target = properties.target; + break; + case "ao.renderer.notification_marked_read": + if (properties?.scope === "single" || properties?.scope === "all") safe.scope = properties.scope; + break; + case "ao.renderer.daemon_failure": + if (typeof properties?.daemon_state === "string") safe.daemon_state = properties.daemon_state; + if (typeof properties?.code === "string") safe.code = properties.code; + if (typeof properties?.exit_code === "number") safe.exit_code = properties.exit_code; + if (typeof properties?.signal === "string") safe.signal = properties.signal; + break; + case "ao.renderer.api_error": + if (typeof properties?.operation === "string") safe.operation = properties.operation; + if (typeof properties?.error_category === "string") safe.error_category = properties.error_category; + if (typeof properties?.status === "number") safe.status = properties.status; + break; + case "ao.renderer.terminal_attach_failed": + if (properties?.reason === "open_timeout" || properties?.reason === "pane_error") { + safe.reason = properties.reason; + } + break; } return safe; } diff --git a/frontend/src/renderer/main.tsx b/frontend/src/renderer/main.tsx index 48ce485a36..a575674db2 100644 --- a/frontend/src/renderer/main.tsx +++ b/frontend/src/renderer/main.tsx @@ -8,9 +8,11 @@ import { queryClient } from "./lib/query-client"; import { createAppRouter } from "./router"; import { TelemetryBoundary } from "./components/TelemetryBoundary"; import { initTelemetry } from "./lib/telemetry"; +import { startDaemonFailureTelemetry } from "./lib/daemon-telemetry"; const router = createAppRouter(queryClient); void initTelemetry(); +startDaemonFailureTelemetry(); declare module "@tanstack/react-router" { interface Register { diff --git a/frontend/src/shared/daemon-attach.test.ts b/frontend/src/shared/daemon-attach.test.ts index 12dba66fde..66da84ccc2 100644 --- a/frontend/src/shared/daemon-attach.test.ts +++ b/frontend/src/shared/daemon-attach.test.ts @@ -174,6 +174,7 @@ describe("resolveDaemonFromRunFile", () => { executablePath: undefined, workingDirectory: undefined, message: "An AO daemon is already running, but it is not ready yet.", + code: "not_ready", }); }); @@ -271,6 +272,7 @@ describe("resolveDaemonFromPort", () => { executablePath: undefined, workingDirectory: undefined, message: "An AO daemon is already running, but it is not ready yet.", + code: "not_ready", }); }); diff --git a/frontend/src/shared/daemon-attach.ts b/frontend/src/shared/daemon-attach.ts index 31bc702fda..8298c7e607 100644 --- a/frontend/src/shared/daemon-attach.ts +++ b/frontend/src/shared/daemon-attach.ts @@ -147,6 +147,7 @@ async function readinessStatus( executablePath: health.executablePath, workingDirectory: health.workingDirectory, message: "An AO daemon is already running, but it is not ready yet.", + code: "not_ready", }; } @@ -159,6 +160,7 @@ async function readinessStatus( executablePath: ready.executablePath, workingDirectory: ready.workingDirectory, message, + code: "identity_mismatch", }; } diff --git a/frontend/src/shared/daemon-status.ts b/frontend/src/shared/daemon-status.ts index 4476d6ffd2..679ea5ce18 100644 --- a/frontend/src/shared/daemon-status.ts +++ b/frontend/src/shared/daemon-status.ts @@ -1,6 +1,19 @@ // DaemonStatus is the supervisor → renderer handshake payload, shared by the // Electron main process (which derives it) and the preload bridge (which types // the IPC surface). The renderer picks it up through the preload's AoBridge type. +// Machine-readable failure classification for telemetry. `message` is +// human-facing and may contain local paths; `code` is what gets reported. +// Statuses without a code (normal ready, user-initiated stop) are not failures. +export type DaemonFailureCode = + | "not_configured" + | "daemon_unreachable" + | "binary_missing" + | "spawn_failed" + | "exited" + | "port_unconfirmed" + | "not_ready" + | "identity_mismatch"; + export type DaemonStatus = { state: "starting" | "ready" | "stopped" | "error"; port?: number; @@ -8,4 +21,7 @@ export type DaemonStatus = { executablePath?: string; workingDirectory?: string; message?: string; + code?: DaemonFailureCode; + exitCode?: number | null; + signal?: string | null; };