Skip to content

Commit 75b8464

Browse files
committed
feat: add workflow telemetry traces
1 parent 4ae51e1 commit 75b8464

10 files changed

Lines changed: 1219 additions & 277 deletions

File tree

src/commands.ts

Lines changed: 396 additions & 247 deletions
Large diffs are not rendered by default.

src/instrumentation/commands.ts

Lines changed: 486 additions & 0 deletions
Large diffs are not rendered by default.

src/instrumentation/workspace.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const PROVISIONING_STATUSES: ReadonlySet<WorkspaceStatus> = new Set([
2424
"deleting",
2525
]);
2626

27+
export type WorkspacePromptAction = "start" | "update";
28+
2729
interface ObservedWorkspaceState {
2830
readonly status: WorkspaceStatus;
2931
readonly buildTransition: WorkspaceBuild["transition"];
@@ -174,6 +176,25 @@ export class WorkspaceOperationTelemetry {
174176
});
175177
}
176178

179+
public async traceStartPrompted(
180+
outdated: boolean,
181+
fn: () => Promise<WorkspacePromptAction | undefined>,
182+
): Promise<WorkspacePromptAction | undefined> {
183+
return this.telemetry.trace(
184+
"workspace.start.prompted",
185+
async (span) => {
186+
const action = await fn();
187+
if (!action) {
188+
span.markAborted();
189+
return undefined;
190+
}
191+
span.setProperty("action", action);
192+
return action;
193+
},
194+
{ "update.offered": outdated },
195+
);
196+
}
197+
177198
/**
178199
* Records dismissal as `result: "aborted"`. The framework treats any throw
179200
* as `result: "error"`, so we return inside the span and rethrow outside.
@@ -196,7 +217,7 @@ export class WorkspaceOperationTelemetry {
196217
throw error;
197218
}
198219
},
199-
{ workspaceName: this.workspaceName },
220+
{ prompt: "parameters" },
200221
);
201222
if (cancel) throw cancel;
202223
return parameters;

src/remote/workspaceStateMachine.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -337,18 +337,20 @@ export class WorkspaceStateMachine implements vscode.Disposable {
337337
workspaceName: string,
338338
outdated: boolean,
339339
): Promise<"start" | "update" | undefined> {
340-
const buttons = outdated ? ["Start", "Update and Start"] : ["Start"];
341-
const action = await vscodeProposed.window.showInformationMessage(
342-
`The workspace ${workspaceName} is not running. How would you like to proceed?`,
343-
{
344-
useCustom: true,
345-
modal: true,
346-
},
347-
...buttons,
348-
);
349-
if (action === "Start") return "start";
350-
if (action === "Update and Start") return "update";
351-
return undefined;
340+
return this.operationTelemetry.traceStartPrompted(outdated, async () => {
341+
const buttons = outdated ? ["Start", "Update and Start"] : ["Start"];
342+
const action = await vscodeProposed.window.showInformationMessage(
343+
`The workspace ${workspaceName} is not running. How would you like to proceed?`,
344+
{
345+
useCustom: true,
346+
modal: true,
347+
},
348+
...buttons,
349+
);
350+
if (action === "Start") return "start";
351+
if (action === "Update and Start") return "update";
352+
return undefined;
353+
});
352354
}
353355

354356
public getAgentId(): string | undefined {

src/telemetry/export/command.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type { Logger } from "../../logging/logger";
1919
import type { TelemetryContext } from "../event";
2020
import type { FlushStatus } from "../service";
2121

22+
import type { ExportFormat } from "./writers/types";
23+
2224
const REVEAL_ACTION = "Reveal in File Explorer";
2325

2426
const PROGRESS_OPTIONS = {
@@ -27,15 +29,24 @@ const PROGRESS_OPTIONS = {
2729
cancellable: true,
2830
} as const;
2931

32+
export type ExportTelemetryOutcome =
33+
| { readonly status: "cancelled"; readonly stage: "prompt" | "progress" }
34+
| { readonly status: "failed"; readonly error: unknown }
35+
| {
36+
readonly status: "success";
37+
readonly eventCount: number;
38+
readonly format: ExportFormat;
39+
};
40+
3041
export async function runExportTelemetryCommand(
3142
telemetryDir: string,
3243
logger: Logger,
3344
flushTelemetry: () => Promise<FlushStatus>,
3445
context: TelemetryContext,
35-
): Promise<void> {
46+
): Promise<ExportTelemetryOutcome> {
3647
const choice = await promptForExport();
3748
if (!choice) {
38-
return;
49+
return { status: "cancelled", stage: "prompt" };
3950
}
4051

4152
const request: ExportRequest = {
@@ -53,7 +64,7 @@ export async function runExportTelemetryCommand(
5364
PROGRESS_OPTIONS,
5465
);
5566

56-
await reportOutcome(result, choice, logger);
67+
return reportOutcome(result, choice, logger);
5768
}
5869

5970
/** Wires the pipeline's host hooks to the progress UI and the logger. */
@@ -81,26 +92,27 @@ async function reportOutcome(
8192
result: ProgressResult<number>,
8293
choice: ExportChoice,
8394
logger: Logger,
84-
): Promise<void> {
95+
): Promise<ExportTelemetryOutcome> {
8596
if (!result.ok) {
8697
if (result.cancelled) {
87-
return;
98+
return { status: "cancelled", stage: "progress" };
8899
}
89100
logger.error("Telemetry export failed", result.error);
90101
void vscode.window.showErrorMessage(
91102
`Telemetry export failed: ${toError(result.error).message}`,
92103
);
93-
return;
104+
return { status: "failed", error: result.error };
94105
}
95106

96107
const eventCount = result.value;
97108
if (eventCount === 0) {
98109
void vscode.window.showInformationMessage(
99110
`No telemetry events found for ${choice.range.label}.`,
100111
);
101-
return;
112+
return { status: "success", eventCount, format: choice.format };
102113
}
103114
await notifyExportSucceeded(choice.outputPath, eventCount, logger);
115+
return { status: "success", eventCount, format: choice.format };
104116
}
105117

106118
async function notifyExportSucceeded(

src/uri/uriHandler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ async function handleOpen(ctx: UriRouteContext): Promise<void> {
8585
agentName: agent ?? undefined,
8686
folderPath: folder ?? undefined,
8787
openRecent,
88+
source: "uri",
8889
useDefaultDirectory: false,
8990
});
9091
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { CommandInstrumentation } from "@/instrumentation/commands";
4+
5+
import { agent, resource, workspace } from "@repo/mocks";
6+
7+
import { createTelemetryHarness } from "../../mocks/telemetry";
8+
9+
function workspaceWithAgents() {
10+
const connected = agent({
11+
status: "connected",
12+
lifecycle_state: "ready",
13+
});
14+
const disconnected = agent({
15+
id: "agent-2",
16+
name: "secondary",
17+
status: "disconnected",
18+
lifecycle_state: "off",
19+
});
20+
return {
21+
connected,
22+
disconnected,
23+
workspace: workspace({
24+
outdated: true,
25+
latest_build: {
26+
status: "running",
27+
resources: [resource({ agents: [connected, disconnected] })],
28+
},
29+
}),
30+
};
31+
}
32+
33+
describe("command instrumentation helpers", () => {
34+
it("records workspace selection without workspace or agent names", async () => {
35+
const { sink, service } = createTelemetryHarness();
36+
const traces = new CommandInstrumentation(service);
37+
const selection = workspaceWithAgents();
38+
39+
await traces.workspaceOpen(
40+
"command",
41+
{ workspace: selection.workspace, agent: selection.connected },
42+
() => Promise.resolve(true),
43+
);
44+
45+
const event = sink.expectOne("workspace.open");
46+
expect(event.properties).toMatchObject({
47+
"agent.lifecycle_state": "ready",
48+
"agent.status": "connected",
49+
"workspace.outdated": "true",
50+
"workspace.status": "running",
51+
result: "success",
52+
});
53+
expect(event.measurements).toMatchObject({
54+
agentCount: 2,
55+
connectedAgentCount: 1,
56+
});
57+
expect(event.properties.workspaceName).toBeUndefined();
58+
expect(event.properties.agentName).toBeUndefined();
59+
});
60+
61+
it("records workspace picker cancellation and failure distinctly", async () => {
62+
const { sink, service } = createTelemetryHarness();
63+
const traces = new CommandInstrumentation(service);
64+
65+
await traces.workspacePicker("workspace.open", (telemetry) => {
66+
telemetry.cancelled(3);
67+
return Promise.resolve({ status: "cancelled" });
68+
});
69+
await traces.workspacePicker("workspace.open", (telemetry) => {
70+
telemetry.failed("fetch_failed", 0);
71+
return Promise.resolve({ status: "failed", category: "fetch_failed" });
72+
});
73+
74+
const [cancelled, failed] = sink.eventsNamed("workspace.picker.prompted");
75+
expect(cancelled.properties).toMatchObject({ result: "aborted" });
76+
expect(cancelled.measurements.workspaceCount).toBe(3);
77+
expect(failed.properties).toMatchObject({
78+
"failure.category": "fetch_failed",
79+
result: "error",
80+
});
81+
expect(failed.measurements.workspaceCount).toBe(0);
82+
});
83+
84+
it("records workspace open cancellation and handled failure distinctly", async () => {
85+
const { sink, service } = createTelemetryHarness();
86+
const traces = new CommandInstrumentation(service);
87+
const selection = workspaceWithAgents();
88+
89+
await traces.workspaceOpen("command", undefined, (telemetry) =>
90+
Promise.resolve(
91+
telemetry.cancel("agent_picker", { workspace: selection.workspace }),
92+
),
93+
);
94+
await traces.workspaceOpen("command", undefined, (telemetry) =>
95+
Promise.resolve(telemetry.fail("fetch_failed")),
96+
);
97+
98+
const [cancelled, failed] = sink.eventsNamed("workspace.open");
99+
expect(cancelled.properties).toMatchObject({
100+
"cancel.stage": "agent_picker",
101+
"workspace.status": "running",
102+
result: "aborted",
103+
});
104+
expect(failed.properties).toMatchObject({
105+
"failure.category": "fetch_failed",
106+
result: "error",
107+
});
108+
});
109+
110+
it("records thrown workspace open failures without raw error details", async () => {
111+
const { sink, service } = createTelemetryHarness();
112+
const traces = new CommandInstrumentation(service);
113+
114+
await expect(
115+
traces.workspaceOpen("command", undefined, () =>
116+
Promise.reject(new Error("secret path /tmp/workspace")),
117+
),
118+
).rejects.toThrow("secret path /tmp/workspace");
119+
120+
const event = sink.expectOne("workspace.open");
121+
expect(event.properties).toMatchObject({
122+
"failure.category": "error",
123+
result: "error",
124+
});
125+
expect(event.error).toBeUndefined();
126+
});
127+
128+
it("records diagnostic cancellation and failure categories", async () => {
129+
const { sink, service } = createTelemetryHarness();
130+
const traces = new CommandInstrumentation(service);
131+
const failure = new Error("boom");
132+
133+
await traces.diagnostic("coder.supportBundle", (telemetry) => {
134+
telemetry.cancel("save_dialog");
135+
return Promise.resolve();
136+
});
137+
await traces.diagnostic("coder.supportBundle", (telemetry) => {
138+
telemetry.fail(failure, "unsupported_cli");
139+
return Promise.resolve();
140+
});
141+
142+
const [cancelled, failed] = sink.eventsNamed(
143+
"command.diagnostic.completed",
144+
);
145+
expect(cancelled.properties).toMatchObject({
146+
"cancel.stage": "save_dialog",
147+
result: "aborted",
148+
});
149+
expect(failed.properties).toMatchObject({
150+
"failure.category": "unsupported_cli",
151+
result: "error",
152+
});
153+
expect(failed.error).toBeUndefined();
154+
});
155+
156+
it("records bounded speed test measurements", async () => {
157+
const { sink, service } = createTelemetryHarness();
158+
const traces = new CommandInstrumentation(service);
159+
160+
await traces.diagnostic("coder.speedTest", (telemetry) => {
161+
const parsed = telemetry.speedtestSuccess(
162+
JSON.stringify({
163+
overall: {
164+
start_time_seconds: 0,
165+
end_time_seconds: 5,
166+
throughput_mbits: 42,
167+
},
168+
intervals: [
169+
{
170+
start_time_seconds: 0,
171+
end_time_seconds: 5,
172+
throughput_mbits: 42,
173+
},
174+
],
175+
}),
176+
);
177+
expect(parsed.overall.throughput_mbits).toBe(42);
178+
return Promise.resolve();
179+
});
180+
181+
expect(sink.expectOne("command.diagnostic.completed")).toMatchObject({
182+
measurements: {
183+
intervalCount: 1,
184+
throughputMbits: 42,
185+
},
186+
properties: { result: "success" },
187+
});
188+
});
189+
190+
it("records thrown devcontainer failures without raw error details", async () => {
191+
const { sink, service } = createTelemetryHarness();
192+
const traces = new CommandInstrumentation(service);
193+
194+
await expect(
195+
traces.devcontainerOpen("dev_container", () =>
196+
Promise.reject(new Error("secret local path /tmp/workspace")),
197+
),
198+
).rejects.toThrow("secret local path /tmp/workspace");
199+
200+
const event = sink.expectOne("workspace.devcontainer.open");
201+
expect(event.properties).toMatchObject({
202+
"failure.category": "error",
203+
mode: "dev_container",
204+
result: "error",
205+
});
206+
expect(event.error).toBeUndefined();
207+
});
208+
});

0 commit comments

Comments
 (0)