feat(telemetry): instrument renderer failure and CTA events (#2360)

This commit is contained in:
Pritom Mazumdar 2026-07-05 21:42:54 +05:30 committed by GitHub
parent 860fa5a242
commit 0f2295c0c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 765 additions and 54 deletions

View File

@ -478,6 +478,7 @@ async function refreshDaemonStatus(): Promise<DaemonStatus> {
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<DaemonStatus> {
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<DaemonStatus> {
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<DaemonStatus> {
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<DaemonStatus> {
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,
});
});

View File

@ -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 {

View File

@ -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) {

View File

@ -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 (

View File

@ -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) {

View File

@ -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",

View File

@ -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) {

View File

@ -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) {

View File

@ -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();

View File

@ -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(

View File

@ -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,14 +40,134 @@ 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<string, number>();
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<Response> {
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" },
});
}
const send = async (): Promise<Response> => {
if (!baseUrl) {
return fetch(input);
}
@ -76,6 +197,22 @@ async function runtimeFetch(input: Request): Promise<Response> {
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<paths>({

View File

@ -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);
});
});

View File

@ -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();
};
}

View File

@ -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");

View File

@ -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",

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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)",
);
});
});

View File

@ -1,9 +1,31 @@
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<string> {
export async function spawnOrchestrator(
projectId: string,
source: OrchestratorSpawnSource,
clean = false,
): Promise<string> {
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 },
});
@ -15,5 +37,10 @@ export async function spawnOrchestrator(projectId: string, clean = false): Promi
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;
}
}

View File

@ -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({});
});
});

View File

@ -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;
}

View File

@ -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 {

View File

@ -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",

View File

@ -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",
});
});

View File

@ -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",
};
}

View File

@ -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;
};