Skip to content

Commit 6410554

Browse files
Pritom14claude
andcommitted
feat(telemetry): instrument renderer failure and CTA events
Add PostHog instrumentation for app failures and user CTAs: - daemon_failure (machine-readable code on DaemonStatus, IPC-forwarded) - api_error central interceptor (categorized, IDs stripped) - terminal_attach_failed (pane error + open timeout) - CTA triads: task_create, session_kill, settings_save, orchestrator_spawn (board/restore_dialog), notification open/read All events sanitized: project_id hashed, enum-only codes, raw error messages never sent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2081f2b commit 6410554

19 files changed

Lines changed: 612 additions & 33 deletions

frontend/src/main.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ async function refreshDaemonStatus(): Promise<DaemonStatus> {
411411
setDaemonStatus({
412412
state: "stopped",
413413
message: "AO daemon is no longer reachable.",
414+
code: "daemon_unreachable",
414415
});
415416
}
416417
return daemonStatus;
@@ -451,6 +452,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
451452
setDaemonStatus({
452453
state: "stopped",
453454
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
455+
code: "not_configured",
454456
});
455457
return daemonStatus;
456458
}
@@ -548,6 +550,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
548550
setDaemonStatus({
549551
state: "error",
550552
message: `Bundled AO daemon binary was not found at ${launch.command}. Rebuild the desktop package.`,
553+
code: "binary_missing",
551554
});
552555
return daemonStatus;
553556
}
@@ -636,6 +639,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
636639
state: "ready",
637640
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
638641
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
642+
code: "port_unconfirmed",
639643
});
640644
}, PORT_DISCOVERY_TIMEOUT_MS);
641645

@@ -644,7 +648,7 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
644648
if (daemonProcess !== child) return;
645649
daemonProcess = null;
646650
if (daemonStoppingProcess === child) daemonStoppingProcess = null;
647-
setDaemonStatus({ state: "error", message: error.message });
651+
setDaemonStatus({ state: "error", message: error.message, code: "spawn_failed" });
648652
});
649653

650654
child.once("exit", (code, signal) => {
@@ -655,6 +659,9 @@ async function startDaemonInner(startEpoch: number): Promise<DaemonStatus> {
655659
setDaemonStatus({
656660
state: "stopped",
657661
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
662+
code: "exited",
663+
exitCode: code,
664+
signal,
658665
});
659666
});
660667

frontend/src/renderer/components/NewTaskDialog.test.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import userEvent from "@testing-library/user-event";
33
import { beforeEach, describe, expect, it, vi } from "vitest";
44
import { NewTaskDialog } from "./NewTaskDialog";
55

6-
const { postMock } = vi.hoisted(() => ({
6+
const { postMock, captureMock } = vi.hoisted(() => ({
77
postMock: vi.fn(),
8+
captureMock: vi.fn().mockResolvedValue(undefined),
89
}));
910

1011
vi.mock("../lib/api-client", () => ({
@@ -19,8 +20,13 @@ vi.mock("../lib/api-client", () => ({
1920
},
2021
}));
2122

23+
vi.mock("../lib/telemetry", () => ({
24+
captureRendererEvent: captureMock,
25+
}));
26+
2227
beforeEach(() => {
2328
postMock.mockReset();
29+
captureMock.mockClear();
2430
postMock.mockResolvedValue({ data: { session: { id: "task-1" } }, error: undefined });
2531
});
2632

@@ -56,5 +62,33 @@ describe("NewTaskDialog", () => {
5662

5763
expect(await screen.findByText("Title and brief are required.")).toBeInTheDocument();
5864
expect(postMock).not.toHaveBeenCalled();
65+
expect(captureMock).not.toHaveBeenCalled();
66+
});
67+
68+
it("emits the task_create triad on success", async () => {
69+
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />);
70+
71+
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
72+
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer.");
73+
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
74+
75+
await waitFor(() =>
76+
expect(captureMock).toHaveBeenCalledWith("ao.renderer.task_create_succeeded", { project_id: "proj-1" }),
77+
);
78+
expect(captureMock).toHaveBeenCalledWith("ao.renderer.task_create_requested", { project_id: "proj-1" });
79+
expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.task_create_failed", expect.anything());
80+
});
81+
82+
it("emits task_create_failed when the daemon rejects the task", async () => {
83+
postMock.mockResolvedValue({ data: undefined, error: { message: "no such project" } });
84+
render(<NewTaskDialog open projectId="proj-1" onCreated={vi.fn()} onOpenChange={vi.fn()} />);
85+
86+
await userEvent.type(screen.getByLabelText("Title"), "Fix fallback renderer");
87+
await userEvent.type(screen.getByLabelText("Brief"), "Restore the fallback renderer.");
88+
await userEvent.click(screen.getByRole("button", { name: "Start task" }));
89+
90+
expect(await screen.findByText("no such project")).toBeInTheDocument();
91+
expect(captureMock).toHaveBeenCalledWith("ao.renderer.task_create_failed", { project_id: "proj-1" });
92+
expect(captureMock).not.toHaveBeenCalledWith("ao.renderer.task_create_succeeded", expect.anything());
5993
});
6094
});

frontend/src/renderer/components/NewTaskDialog.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from "./ui/button";
55
import { Input } from "./ui/input";
66
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
77
import { apiClient, apiErrorMessage } from "../lib/api-client";
8+
import { captureRendererEvent } from "../lib/telemetry";
89
import type { AgentProvider } from "../types/workspace";
910

1011
type NewTaskDialogProps = {
@@ -57,6 +58,7 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
5758

5859
setIsSubmitting(true);
5960
setError(undefined);
61+
void captureRendererEvent("ao.renderer.task_create_requested", { project_id: projectId });
6062
try {
6163
const { data, error: apiError } = await apiClient.POST("/api/v1/sessions", {
6264
body: {
@@ -70,9 +72,11 @@ export function NewTaskDialog({ open, projectId, onCreated, onOpenChange }: NewT
7072
});
7173
if (apiError) throw new Error(apiErrorMessage(apiError, "Unable to start task"));
7274
if (!data?.session?.id) throw new Error("Task creation returned no session");
75+
void captureRendererEvent("ao.renderer.task_create_succeeded", { project_id: projectId });
7376
onCreated(data.session.id);
7477
onOpenChange(false);
7578
} catch (err) {
79+
void captureRendererEvent("ao.renderer.task_create_failed", { project_id: projectId });
7680
setError(err instanceof Error ? err.message : "Unable to start task");
7781
} finally {
7882
setIsSubmitting(false);

frontend/src/renderer/components/NotificationCenter.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { aoBridge } from "../lib/bridge";
1111
import { formatTimeCompact } from "../lib/format-time";
1212
import { createNotificationsTransport, type NotificationDTO, unreadNotificationsQueryKey } from "../lib/notifications";
13+
import { captureRendererEvent } from "../lib/telemetry";
1314
import { cn } from "../lib/utils";
1415
import {
1516
DropdownMenu,
@@ -37,11 +38,13 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
3738
(notification: NotificationDTO) => {
3839
const target = notification.target;
3940
if (target.kind === "pr" && target.prUrl) {
41+
void captureRendererEvent("ao.renderer.notification_opened", { target: "pr" });
4042
window.open(target.prUrl, "_blank", "noopener,noreferrer");
4143
return;
4244
}
4345
const sessionId = target.sessionId || notification.sessionId;
4446
if (!sessionId) return;
47+
void captureRendererEvent("ao.renderer.notification_opened", { target: "session" });
4548
if (notification.projectId) {
4649
void navigate({
4750
to: "/projects/$projectId/sessions/$sessionId",
@@ -66,6 +69,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
6669

6770
const markOneRead = async (id: string) => {
6871
setActionError(null);
72+
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "single" });
6973
try {
7074
await markRead.mutateAsync(id);
7175
} catch (error) {
@@ -75,6 +79,7 @@ export function NotificationCenter({ style }: NotificationCenterProps) {
7579

7680
const markAll = async () => {
7781
setActionError(null);
82+
void captureRendererEvent("ao.renderer.notification_marked_read", { scope: "all" });
7883
try {
7984
await markAllRead.mutateAsync();
8085
} catch (error) {

frontend/src/renderer/components/ProjectSettingsForm.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
import { useState } from "react";
33
import type { components } from "../../api/schema";
44
import { apiClient, apiErrorMessage } from "../lib/api-client";
5+
import { captureRendererEvent } from "../lib/telemetry";
56
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
67
import { RequiredAgentField } from "./CreateProjectAgentSheet";
78
import { DashboardSubhead } from "./DashboardSubhead";
@@ -81,6 +82,7 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
8182

8283
const mutation = useMutation({
8384
mutationFn: async () => {
85+
void captureRendererEvent("ao.renderer.settings_save_requested", { project_id: projectId });
8486
// PUT replaces the whole config; merge the edited fields over what loaded
8587
// so we don't drop env/symlinks/postCreate the form doesn't expose.
8688
const next: ProjectConfig = {
@@ -103,11 +105,15 @@ function SettingsBody({ project, projectId, onSaved }: { project: Project; proje
103105
if (error) throw new Error(apiErrorMessage(error));
104106
},
105107
onSuccess: () => {
108+
void captureRendererEvent("ao.renderer.settings_save_succeeded", { project_id: projectId });
106109
setSavedAt(Date.now());
107110
setValidationError(null);
108111
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
109112
onSaved();
110113
},
114+
onError: () => {
115+
void captureRendererEvent("ao.renderer.settings_save_failed", { project_id: projectId });
116+
},
111117
});
112118

113119
return (

frontend/src/renderer/components/RestoreUnavailableDialog.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react";
33
import { useState } from "react";
44
import { Button } from "./ui/button";
55
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
6+
import { captureRendererEvent } from "../lib/telemetry";
67
import { isOrchestratorSession } from "../types/workspace";
78
import type { WorkspaceSession } from "../types/workspace";
89

@@ -21,11 +22,23 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
2122
const recreate = async () => {
2223
setBusy(true);
2324
setError(undefined);
25+
void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", {
26+
project_id: session.workspaceId,
27+
source: "restore_dialog",
28+
});
2429
try {
2530
const id = await spawnOrchestrator(session.workspaceId, true);
31+
void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", {
32+
project_id: session.workspaceId,
33+
source: "restore_dialog",
34+
});
2635
onOpenChange(false);
2736
onRecreated(id);
2837
} catch (err) {
38+
void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", {
39+
project_id: session.workspaceId,
40+
source: "restore_dialog",
41+
});
2942
setError(err instanceof Error ? err.message : "Failed to create orchestrator");
3043
} finally {
3144
setBusy(false);

frontend/src/renderer/components/SessionsBoard.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DashboardSubhead } from "./DashboardSubhead";
99
import { OrchestratorIcon } from "./icons";
1010
import { NewTaskDialog } from "./NewTaskDialog";
1111
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
12+
import { captureRendererEvent } from "../lib/telemetry";
1213
import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
1314
import { cn } from "../lib/utils";
1415
import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay";
@@ -103,13 +104,18 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
103104
return;
104105
}
105106
setIsSpawning(true);
107+
void captureRendererEvent("ao.renderer.orchestrator_spawn_requested", { project_id: projectId, source: "board" });
106108
try {
107109
const sessionId = await spawnOrchestrator(projectId);
110+
void captureRendererEvent("ao.renderer.orchestrator_spawn_succeeded", { project_id: projectId, source: "board" });
108111
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
109112
void navigate({
110113
to: "/projects/$projectId/sessions/$sessionId",
111114
params: { projectId, sessionId },
112115
});
116+
} catch (error) {
117+
void captureRendererEvent("ao.renderer.orchestrator_spawn_failed", { project_id: projectId, source: "board" });
118+
throw error;
113119
} finally {
114120
setIsSpawning(false);
115121
}

frontend/src/renderer/components/ShellTopbar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,21 @@ export function TopbarKillButton({ session }: { session: WorkspaceSession }) {
249249

250250
const kill = useMutation({
251251
mutationFn: async () => {
252+
void captureRendererEvent("ao.renderer.session_kill_requested", { project_id: session.workspaceId });
252253
const { error: apiError } = await apiClient.POST("/api/v1/sessions/{sessionId}/kill", {
253254
params: { path: { sessionId: session.id } },
254255
});
255256
if (apiError) throw new Error(apiErrorMessage(apiError));
256257
},
257258
onSuccess: () => {
259+
void captureRendererEvent("ao.renderer.session_kill_succeeded", { project_id: session.workspaceId });
258260
setConfirming(false);
259261
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
260262
},
261-
onError: (e) => setError(e instanceof Error ? e.message : "Kill failed"),
263+
onError: (e) => {
264+
void captureRendererEvent("ao.renderer.session_kill_failed", { project_id: session.workspaceId });
265+
setError(e instanceof Error ? e.message : "Kill failed");
266+
},
262267
});
263268

264269
if (confirming) {

frontend/src/renderer/hooks/useTerminalSession.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { useQueryClient } from "@tanstack/react-query";
1111
import { useCallback, useEffect, useRef, useState } from "react";
1212
import { getApiBaseUrl } from "../lib/api-client";
13+
import { captureRendererEvent } from "../lib/telemetry";
1314
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
1415
import type { WorkspaceSession } from "../types/workspace";
1516
import { workspaceQueryKey } from "./useWorkspaceQuery";
@@ -212,6 +213,7 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
212213
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
213214
setError(message);
214215
transition("error");
216+
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "pane_error" });
215217
invalidateWorkspaces();
216218
}),
217219
mux.onConnectionChange((connectionState) => {
@@ -267,6 +269,11 @@ export function useTerminalSession(session: WorkspaceSession | undefined, option
267269
r.openTimer = setTimeout(() => {
268270
if (!isCurrentAttachment(generation, handle, mux)) return;
269271
r.openTimer = null;
272+
// Only the first timeout of a reattach sequence is reported; the
273+
// backoff loop retrying against a restarting daemon is not news.
274+
if (r.attempts === 0) {
275+
void captureRendererEvent("ao.renderer.terminal_attach_failed", { reason: "open_timeout" });
276+
}
270277
transition("reattaching");
271278
teardownMux();
272279
scheduleReattach();

0 commit comments

Comments
 (0)