diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c408e41c9..835440a01 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,17 +749,29 @@ 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) => { stopDiscovery(); if (daemonProcess !== child) return; daemonProcess = null; - if (daemonStoppingProcess === child) daemonStoppingProcess = null; + // An explicit stopDaemon() already set a clean `{ state: "stopped" }`. + // daemon-telemetry reports any status carrying a `code` as + // ao.renderer.daemon_failure, so don't stamp `code: "exited"` on a stop + // the user or app asked for — that would count intentional stops as + // failures. Preserve the clean stopped status instead. + if (daemonStoppingProcess === child) { + daemonStoppingProcess = null; + setDaemonStatus({ state: "stopped" }); + return; + } 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 80a9bcddc..a51f379d9 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"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; @@ -88,6 +89,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: { @@ -101,9 +103,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 }); void queryClient.invalidateQueries({ queryKey: agentsQueryKey }); setError(err instanceof Error ? err.message : "Unable to start task"); } finally { diff --git a/frontend/src/renderer/components/NotificationCenter.tsx b/frontend/src/renderer/components/NotificationCenter.tsx index 582205261..02da22b80 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 513567027..f9a815678 100644 --- a/frontend/src/renderer/components/ProjectSettingsForm.tsx +++ b/frontend/src/renderer/components/ProjectSettingsForm.tsx @@ -4,6 +4,7 @@ import type { components } from "../../api/schema"; import { agentsQueryKey, agentsQueryOptions, refreshAgents } from "../hooks/useAgentsQuery"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { captureRendererEvent } from "../lib/telemetry"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { newestActiveOrchestrator } from "../types/workspace"; import { RequiredAgentField } from "./CreateProjectAgentSheet"; @@ -120,6 +121,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 = { @@ -146,7 +148,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje (activeOrchestrator && activeOrchestrator.provider !== form.orchestratorAgent) ) { try { - await spawnOrchestrator(projectId, true); + await spawnOrchestrator(projectId, "settings", true); } catch (error) { return { replacementError: error instanceof Error ? error.message : "Could not replace orchestrator", @@ -156,12 +158,16 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje return { replacementError: null }; }, onSuccess: (result) => { + void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId }); setSavedAt(Date.now()); setReplacementError(result.replacementError); setValidationError(null); 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 d5f99d338..f568cc01d 100644 --- a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx +++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx @@ -22,7 +22,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea setBusy(true); setError(undefined); try { - const id = await spawnOrchestrator(session.workspaceId, true); + const id = await spawnOrchestrator(session.workspaceId, "restore_dialog", true); onOpenChange(false); onRecreated(id); } catch (err) { diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index ae7f12e7f..0103a5c30 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -117,7 +117,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(projectId); + const sessionId = await spawnOrchestrator(projectId, "board"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index 2929475c7..892fcabbd 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -112,7 +112,7 @@ export function ShellTopbar() { } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(projectId); + const sessionId = await spawnOrchestrator(projectId, "topbar"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -252,16 +252,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/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index eb54439e8..89c480fe9 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -445,7 +445,7 @@ function ProjectItem({ } setIsSpawning(true); try { - const sessionId = await spawnOrchestrator(workspace.id); + const sessionId = await spawnOrchestrator(workspace.id, "sidebar"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); selection.goSession(workspace.id, sessionId); } catch (err) { diff --git a/frontend/src/renderer/hooks/useTerminalSession.ts b/frontend/src/renderer/hooks/useTerminalSession.ts index 70365e350..0ecfcfc21 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"; @@ -213,6 +214,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) => { @@ -268,6 +270,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 df7898253..fe527981f 100644 --- a/frontend/src/renderer/lib/api-client.test.ts +++ b/frontend/src/renderer/lib/api-client.test.ts @@ -1,12 +1,20 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { apiClient, apiErrorMessage, 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(() => { @@ -162,6 +170,123 @@ describe("subscribeApiBaseUrl", () => { }); }); +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"); + }); + + it("keeps static child routes instead of treating them as ids", () => { + // These match an exact OpenAPI template, so the trailing segment must not + // be collapsed to :id (which would break aggregation and hide the route). + expect(normalizeApiOperation("POST", "/api/v1/notifications/read-all")).toBe("POST /api/v1/notifications/read-all"); + expect(normalizeApiOperation("POST", "/api/v1/sessions/cleanup")).toBe("POST /api/v1/sessions/cleanup"); + }); + + it("normalizes ids for resources a collection heuristic would miss", () => { + expect(normalizeApiOperation("GET", "/api/v1/orchestrators/orch-abc")).toBe("GET /api/v1/orchestrators/:id"); + expect(normalizeApiOperation("POST", "/api/v1/prs/pr-1/merge")).toBe("POST /api/v1/prs/:id/merge"); + }); +}); + +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); + }); +}); + describe("apiErrorMessage", () => { it("preserves daemon error codes next to human messages", () => { expect(apiErrorMessage({ code: "AGENT_BINARY_NOT_FOUND", message: "agent binary not found on PATH" })).toBe( diff --git a/frontend/src/renderer/lib/api-client.ts b/frontend/src/renderer/lib/api-client.ts index 8bc2551aa..6afdecde1 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,179 @@ export function setApiBaseUrl(nextBaseUrl: string | null): void { baseUrlListeners.forEach((listener) => listener()); } +// Route templates from the generated OpenAPI schema (frontend/src/api/schema.ts). +// Operation strings sent to telemetry must never contain raw IDs (project IDs +// are user-chosen strings), so we match each request path against these +// templates and report the template — collapsing `{param}` to `:id` — rather +// than guessing which segments are identifiers. Matching from the schema keeps +// static child routes (notifications/read-all, sessions/cleanup) intact and +// still normalizes IDs for every resource, including ones a segment heuristic +// would miss (orchestrators/{id}). Keep in sync with schema.ts. +const ROUTE_TEMPLATES = [ + "/api/v1/events", + "/api/v1/import", + "/api/v1/notifications", + "/api/v1/notifications/{id}", + "/api/v1/notifications/read-all", + "/api/v1/notifications/stream", + "/api/v1/orchestrators", + "/api/v1/orchestrators/{id}", + "/api/v1/projects", + "/api/v1/projects/{id}", + "/api/v1/projects/{id}/config", + "/api/v1/prs/{id}/merge", + "/api/v1/prs/{id}/resolve-comments", + "/api/v1/sessions", + "/api/v1/sessions/{sessionId}", + "/api/v1/sessions/{sessionId}/activity", + "/api/v1/sessions/{sessionId}/kill", + "/api/v1/sessions/{sessionId}/pr", + "/api/v1/sessions/{sessionId}/pr/claim", + "/api/v1/sessions/{sessionId}/preview", + "/api/v1/sessions/{sessionId}/preview/files/*", + "/api/v1/sessions/{sessionId}/restore", + "/api/v1/sessions/{sessionId}/reviews", + "/api/v1/sessions/{sessionId}/reviews/submit", + "/api/v1/sessions/{sessionId}/reviews/trigger", + "/api/v1/sessions/{sessionId}/rollback", + "/api/v1/sessions/{sessionId}/send", + "/api/v1/sessions/cleanup", +] as const; + +// Resource collections whose next path segment is an identifier. Only used as a +// defensive fallback for paths not covered by ROUTE_TEMPLATES; keeps IDs out of +// telemetry for known collections even if a route is ever missed above. +const RESOURCE_SEGMENTS = new Set(["projects", "sessions", "notifications", "workspaces", "prs", "orchestrators"]); + +// Match a path against one template. `{param}` matches any single segment +// (reported as `:id`), a trailing `*` matches the remaining path, and every +// other segment must match literally. Returns the normalized template plus a +// score = number of literal segments matched, so the most specific template +// wins when several match (e.g. `read-all` beats `{id}`). +function matchRouteTemplate(pathname: string, template: string): { normalized: string; score: number } | null { + const pathSegs = pathname.split("/"); + const tmplSegs = template.split("/"); + const out: string[] = []; + let score = 0; + for (let i = 0; i < tmplSegs.length; i += 1) { + const t = tmplSegs[i]; + if (t === "*") { + out.push("*"); + return { normalized: out.join("/"), score }; + } + const p = pathSegs[i]; + if (p === undefined) return null; + if (t.startsWith("{") && t.endsWith("}")) { + out.push(":id"); + } else if (t === p) { + out.push(t); + score += 1; + } else { + return null; + } + } + if (pathSegs.length !== tmplSegs.length) return null; + return { normalized: out.join("/"), score }; +} + +function fallbackNormalize(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 segments.join("/"); +} + +export function normalizeApiOperation(method: string, pathname: string): string { + let best: { normalized: string; score: number } | null = null; + for (const template of ROUTE_TEMPLATES) { + const match = matchRouteTemplate(pathname, template); + if (match && (best === null || match.score > best.score)) best = match; + } + return `${method.toUpperCase()} ${best?.normalized ?? fallbackNormalize(pathname)}`; +} + +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 000000000..718f120cd --- /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 000000000..a02987227 --- /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/restart-orchestrator.test.ts b/frontend/src/renderer/lib/restart-orchestrator.test.ts index 8b85b9bf8..a202fc21b 100644 --- a/frontend/src/renderer/lib/restart-orchestrator.test.ts +++ b/frontend/src/renderer/lib/restart-orchestrator.test.ts @@ -36,7 +36,7 @@ describe("restartProjectOrchestrator", () => { onError, }); - expect(spawnMock).toHaveBeenCalledWith("proj-1", true); + expect(spawnMock).toHaveBeenCalledWith("proj-1", "restart", true); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey }); expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null); expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary"); diff --git a/frontend/src/renderer/lib/restart-orchestrator.ts b/frontend/src/renderer/lib/restart-orchestrator.ts index d921a4c80..c1619add1 100644 --- a/frontend/src/renderer/lib/restart-orchestrator.ts +++ b/frontend/src/renderer/lib/restart-orchestrator.ts @@ -36,7 +36,7 @@ export async function restartProjectOrchestrator({ setProjectRestarting(projectId, true); setOrchestratorReplacementError(projectId, null); try { - const sessionId = await spawnOrchestrator(projectId, true); + const sessionId = await spawnOrchestrator(projectId, "restart", true); await refreshWorkspaceState(queryClient); void navigate({ to: "/projects/$projectId/sessions/$sessionId", diff --git a/frontend/src/renderer/lib/spawn-orchestrator.test.ts b/frontend/src/renderer/lib/spawn-orchestrator.test.ts index a8748fd20..2d547fadd 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.test.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { spawnOrchestrator } from "./spawn-orchestrator"; import { apiClient } from "./api-client"; +import { captureRendererEvent } from "./telemetry"; vi.mock("./api-client", () => ({ apiClient: { POST: vi.fn() }, @@ -14,6 +15,12 @@ vi.mock("./api-client", () => ({ }, })); +vi.mock("./telemetry", () => ({ + captureRendererEvent: vi.fn().mockResolvedValue(undefined), +})); + +const captureMock = vi.mocked(captureRendererEvent); + describe("spawnOrchestrator", () => { beforeEach(() => vi.clearAllMocks()); @@ -23,7 +30,7 @@ describe("spawnOrchestrator", () => { error: undefined, response: { status: 201 }, }); - const id = await spawnOrchestrator("proj", true); + const id = await spawnOrchestrator("proj", "restore_dialog", true); expect(id).toBe("proj-9"); expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { body: { projectId: "proj", clean: true }, @@ -36,12 +43,43 @@ describe("spawnOrchestrator", () => { error: undefined, response: { status: 201 }, }); - await spawnOrchestrator("proj"); + await spawnOrchestrator("proj", "board"); expect(apiClient.POST).toHaveBeenCalledWith("/api/v1/orchestrators", { body: { projectId: "proj", clean: false }, }); }); + it("emits the requested + succeeded triad keyed by source", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: { orchestrator: { id: "proj-7" } }, + error: undefined, + response: { status: 201 }, + }); + await spawnOrchestrator("proj", "sidebar"); + expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_requested", { + project_id: "proj", + source: "sidebar", + }); + expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", { + project_id: "proj", + source: "sidebar", + }); + }); + + it("emits the failed event and rethrows when the daemon rejects the spawn", async () => { + (apiClient.POST as ReturnType).mockResolvedValue({ + data: undefined, + error: { message: "boom" }, + response: { status: 500 }, + }); + await expect(spawnOrchestrator("proj", "topbar")).rejects.toThrow("boom"); + expect(captureMock).toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_failed", { + project_id: "proj", + source: "topbar", + }); + expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.orchestrator_spawn_succeeded", expect.anything()); + }); + it("surfaces daemon spawn error messages and codes", async () => { (apiClient.POST as ReturnType).mockResolvedValue({ data: undefined, @@ -49,6 +87,8 @@ describe("spawnOrchestrator", () => { response: { status: 400 }, }); - await expect(spawnOrchestrator("proj")).rejects.toThrow("agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)"); + await expect(spawnOrchestrator("proj", "board")).rejects.toThrow( + "agent binary not found on PATH (AGENT_BINARY_NOT_FOUND)", + ); }); }); diff --git a/frontend/src/renderer/lib/spawn-orchestrator.ts b/frontend/src/renderer/lib/spawn-orchestrator.ts index 13d2f5533..c1be2ad62 100644 --- a/frontend/src/renderer/lib/spawn-orchestrator.ts +++ b/frontend/src/renderer/lib/spawn-orchestrator.ts @@ -1,19 +1,46 @@ import { apiClient, apiErrorMessage } from "./api-client"; +import { captureRendererEvent } from "./telemetry"; + +// Every UI entry point that spawns an orchestrator: the board CTA, the topbar +// and sidebar launchers, the restore-unavailable dialog, and the auto-spawn +// right after a project is added. Emitting the triad from inside +// spawnOrchestrator (keyed by source) guarantees each path reports, instead of +// each call site remembering to instrument itself. Keep in sync with the +// allowed-source list in telemetry.ts. +export type OrchestratorSpawnSource = + | "board" + | "restore_dialog" + | "topbar" + | "sidebar" + | "project_add" + | "settings" + | "restart"; /** Spawn the project's orchestrator session via the daemon API. When clean is * true the daemon first tears down any active orchestrator for the project, then * re-spawns one on the canonical branch (reattaching the existing branch). */ -export async function spawnOrchestrator(projectId: string, clean = false): Promise { - const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", { - body: { projectId, clean }, - }); +export async function spawnOrchestrator( + projectId: string, + source: OrchestratorSpawnSource, + clean = false, +): Promise { + void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source }); + try { + const { data, error, response } = await apiClient.POST("/api/v1/orchestrators", { + body: { projectId, clean }, + }); - if (error || !data?.orchestrator?.id) { - const message = error - ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`) - : `Failed to spawn orchestrator (${response.status})`; - throw new Error(message); + if (error || !data?.orchestrator?.id) { + const message = error + ? apiErrorMessage(error, `Failed to spawn orchestrator (${response.status})`) + : `Failed to spawn orchestrator (${response.status})`; + throw new Error(message); + } + + void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source }); + return data.orchestrator.id; + } catch (err) { + void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source }); + throw err; } - - return data.orchestrator.id; } diff --git a/frontend/src/renderer/lib/telemetry.test.ts b/frontend/src/renderer/lib/telemetry.test.ts index 34e70d9e3..b14cb1cf5 100644 --- a/frontend/src/renderer/lib/telemetry.test.ts +++ b/frontend/src/renderer/lib/telemetry.test.ts @@ -111,4 +111,95 @@ 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 every whitelisted spawn source, including topbar/sidebar/project_add/settings/restart", async () => { + for (const source of ["board", "restore_dialog", "topbar", "sidebar", "project_add", "settings", "restart"]) { + const props = await sanitizeRendererProperties("ao.renderer.orchestrator_spawn_succeeded", { + project_id: "demo-project", + source, + }); + expect(Object.keys(props).sort()).toEqual(["project_id_hash", "source"]); + expect(props.source).toBe(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 100c290b5..e71f0bdbb 100644 --- a/frontend/src/renderer/lib/telemetry.ts +++ b/frontend/src/renderer/lib/telemetry.ts @@ -128,6 +128,19 @@ async function sanitizeRendererContextProperties(properties?: TelemetryPropertie return safe; } +// Allowed `source` enum for the orchestrator-spawn triad. Kept as a literal set +// here (rather than imported from spawn-orchestrator.ts, which imports this +// module) to avoid a cycle; keep in sync with OrchestratorSpawnSource. +const ORCHESTRATOR_SPAWN_SOURCES = new Set([ + "board", + "restore_dialog", + "topbar", + "sidebar", + "project_add", + "settings", + "restart", +]); + export async function sanitizeRendererProperties( event: string, properties?: TelemetryProperties, @@ -147,11 +160,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 (typeof properties?.source === "string" && ORCHESTRATOR_SPAWN_SOURCES.has(properties.source)) { + 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 48ce485a3..a575674db 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/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx index 8891662b2..9e176122d 100644 --- a/frontend/src/renderer/routes/_shell.tsx +++ b/frontend/src/renderer/routes/_shell.tsx @@ -112,7 +112,7 @@ function ShellLayout() { void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id }); updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]); try { - const sessionId = await spawnOrchestrator(workspace.id); + const sessionId = await spawnOrchestrator(workspace.id, "project_add"); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); void navigate({ to: "/projects/$projectId/sessions/$sessionId", diff --git a/frontend/src/shared/daemon-attach.test.ts b/frontend/src/shared/daemon-attach.test.ts index 12dba66fd..66da84ccc 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 31bc702fd..8298c7e60 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 4476d6ffd..679ea5ce1 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; };