Skip to content

Commit 227c58d

Browse files
committed
feat(telemetry): add Coder telemetry export command
Adds the "Coder: Export Telemetry" command that lets users save recorded telemetry as a JSON array or an OTLP/JSON zip. The flow prompts for a date range, a format, and a save location, then streams events from the on-disk sink through the chosen writer inside a cancellable progress notification. TelemetryService gains public getContext() and flush() methods so the command can attach an export-time snapshot to the OTLP zip and drain pending events before listing files. #emit routes through getContext() so the snapshot stays in sync with what events carry. Flush and file-listing run inside withCancellableProgress so the on-disk snapshot is taken right before streaming; the AbortSignal is threaded through the event iterator and into the OTLP writer. Reveal errors are swallowed locally so a missing revealFileInOS handler (web/remote hosts) does not report "Telemetry export failed" after a successful save. Writer failures show a single error notification instead of re-throwing into the wrapping command.invoked trace (which would leak the user's chosen save path via buildErrorBlock). Prompts set ignoreFocusOut so an accidental focus loss does not silently abort, and the custom-date prompt states the current UTC date explicitly. The empty-events case removes the empty output file and shows an info notification. Adds unit tests for command orchestration, TelemetryService.getContext / flush, plus a Uri.fsPath getter on the vscode test mock so fsPath template-literal interpolations stop rendering "undefined".
1 parent d8aa467 commit 227c58d

9 files changed

Lines changed: 890 additions & 1 deletion

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,11 @@
422422
"title": "Coder: View Logs",
423423
"icon": "$(list-unordered)"
424424
},
425+
{
426+
"command": "coder.exportTelemetry",
427+
"title": "Coder: Export Telemetry",
428+
"icon": "$(save)"
429+
},
425430
{
426431
"command": "coder.openAppStatus",
427432
"title": "Open App Status",
@@ -536,6 +541,10 @@
536541
"command": "coder.viewLogs",
537542
"when": "true"
538543
},
544+
{
545+
"command": "coder.exportTelemetry",
546+
"when": "true"
547+
},
539548
{
540549
"command": "coder.openAppStatus",
541550
"when": "false"

src/commands.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
applySettingOverrides,
2727
} from "./remote/sshOverrides";
2828
import { resolveCliAuth } from "./settings/cli";
29+
import { runExportTelemetryCommand } from "./telemetry/export/command";
2930
import { toRemoteAuthority, toSafeHost } from "./util";
3031
import { vscodeProposed } from "./vscodeProposed";
3132
import { parseSpeedtestResult } from "./webviews/speedtest/types";
@@ -49,6 +50,7 @@ import type { SecretsManager } from "./core/secretsManager";
4950
import type { DeploymentManager } from "./deployment/deploymentManager";
5051
import type { Logger } from "./logging/logger";
5152
import type { LoginCoordinator } from "./login/loginCoordinator";
53+
import type { TelemetryService } from "./telemetry/service";
5254
import type { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory";
5355
import type {
5456
DuplicateWorkspaceIpc,
@@ -80,6 +82,7 @@ export class Commands {
8082
private readonly loginCoordinator: LoginCoordinator;
8183
private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc;
8284
private readonly speedtestPanelFactory: SpeedtestPanelFactory;
85+
private readonly telemetryService: TelemetryService;
8386

8487
// These will only be populated when actively connected to a workspace and are
8588
// used in commands. Because commands can be executed by the user, it is not
@@ -97,6 +100,7 @@ export class Commands {
97100
private readonly extensionClient: CoderApi,
98101
private readonly deploymentManager: DeploymentManager,
99102
) {
103+
this.telemetryService = serviceContainer.getTelemetryService();
100104
this.logger = serviceContainer.getLogger();
101105
this.pathResolver = serviceContainer.getPathResolver();
102106
this.mementoManager = serviceContainer.getMementoManager();
@@ -350,6 +354,15 @@ export class Commands {
350354
});
351355
}
352356

357+
public async exportTelemetry(): Promise<void> {
358+
await runExportTelemetryCommand(
359+
this.pathResolver.getTelemetryPath(),
360+
this.logger,
361+
() => this.telemetryService.flush(),
362+
this.telemetryService.getContext(),
363+
);
364+
}
365+
353366
/**
354367
* View the logs for the currently connected workspace.
355368
*/

src/core/commandManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const CODER_COMMAND_IDS = [
2020
"coder.navigateToWorkspaceSettings",
2121
"coder.refreshWorkspaces",
2222
"coder.viewLogs",
23+
"coder.exportTelemetry",
2324
"coder.searchMyWorkspaces",
2425
"coder.searchAllWorkspaces",
2526
"coder.manageCredentials",

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ async function doActivate(
295295
void allWorkspacesProvider.fetchAndRefresh();
296296
});
297297
commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands));
298+
commandManager.register(
299+
"coder.exportTelemetry",
300+
commands.exportTelemetry.bind(commands),
301+
);
298302
commandManager.register("coder.searchMyWorkspaces", async () =>
299303
showTreeViewSearch(MY_WORKSPACES_TREE_ID),
300304
);

src/telemetry/export/command.ts

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import * as fs from "node:fs/promises";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import * as vscode from "vscode";
5+
6+
import { toError } from "../../error/errorUtils";
7+
import { withCancellableProgress } from "../../progress";
8+
9+
import { listTelemetryFilesForRange, streamTelemetryEvents } from "./files";
10+
import {
11+
TELEMETRY_RANGE_PRESETS,
12+
createCustomDateRange,
13+
createPresetDateRange,
14+
validateUtcDateInput,
15+
type TelemetryDateRange,
16+
type TelemetryRangePresetId,
17+
} from "./range";
18+
import { writeJsonArrayExport } from "./writers/json";
19+
import { writeOtlpZipExport } from "./writers/otlp/writer";
20+
21+
import type { Logger } from "../../logging/logger";
22+
import type { TelemetryContext, TelemetryEvent } from "../event";
23+
24+
interface FormatPick extends vscode.QuickPickItem {
25+
readonly id: "json" | "otlp";
26+
}
27+
28+
interface RangePick extends vscode.QuickPickItem {
29+
readonly id: TelemetryRangePresetId | "custom";
30+
}
31+
32+
const FORMAT_PICKS: readonly FormatPick[] = [
33+
{
34+
id: "json",
35+
label: "JSON array",
36+
detail: "Single JSON document for human inspection or compliance review.",
37+
},
38+
{
39+
id: "otlp",
40+
label: "OTLP/JSON zip",
41+
detail:
42+
"Zip containing logs.json, traces.json, and metrics.json for OTLP endpoints.",
43+
},
44+
];
45+
46+
interface ExportSummary {
47+
readonly filesScanned: number;
48+
readonly eventCount: number;
49+
}
50+
51+
export async function runExportTelemetryCommand(
52+
telemetryDir: string,
53+
logger: Logger,
54+
flushTelemetry: () => Promise<void>,
55+
context: TelemetryContext,
56+
): Promise<void> {
57+
const range = await promptDateRange();
58+
if (!range) {
59+
return;
60+
}
61+
const format = await promptFormat();
62+
if (!format) {
63+
return;
64+
}
65+
const outputUri = await promptOutputUri(range, format.id);
66+
if (!outputUri) {
67+
return;
68+
}
69+
70+
const onCleanupError = (err: unknown, tempPath: string): void => {
71+
logger.warn("Failed to delete telemetry export temp file", tempPath, err);
72+
};
73+
const onStagingCleanupError = (err: unknown, dir: string): void => {
74+
logger.warn(
75+
"Failed to delete telemetry export staging directory",
76+
dir,
77+
err,
78+
);
79+
};
80+
81+
// Flush + list happen inside the progress callback so the snapshot of
82+
// files-on-disk is taken right before streaming (closing the window during
83+
// which the live sink could rotate a segment or cross UTC midnight), and so
84+
// the user can cancel a long flush via the notification.
85+
const result = await withCancellableProgress(
86+
async ({ signal, progress }): Promise<ExportSummary> => {
87+
progress.report({ message: "Flushing buffered events..." });
88+
await flushTelemetry();
89+
throwIfAborted(signal);
90+
91+
progress.report({ message: "Locating telemetry files..." });
92+
const filePaths = await listTelemetryFilesForRange(telemetryDir, range);
93+
if (filePaths.length === 0) {
94+
return { filesScanned: 0, eventCount: 0 };
95+
}
96+
97+
progress.report({ message: "Writing export..." });
98+
const events = withCancellation(
99+
streamTelemetryEvents(filePaths, range),
100+
signal,
101+
);
102+
const eventCount = await writeExport(
103+
format.id,
104+
outputUri.fsPath,
105+
events,
106+
context,
107+
onCleanupError,
108+
onStagingCleanupError,
109+
signal,
110+
);
111+
return { filesScanned: filePaths.length, eventCount };
112+
},
113+
{
114+
location: vscode.ProgressLocation.Notification,
115+
title: "Exporting Coder telemetry",
116+
cancellable: true,
117+
},
118+
);
119+
120+
if (!result.ok) {
121+
if (result.cancelled) {
122+
return;
123+
}
124+
logger.error("Telemetry export failed", result.error);
125+
vscode.window.showErrorMessage(
126+
`Telemetry export failed: ${toError(result.error).message}`,
127+
);
128+
return;
129+
}
130+
131+
const summary = result.value;
132+
if (summary.filesScanned === 0) {
133+
vscode.window.showInformationMessage(
134+
`No telemetry files found for ${range.label}.`,
135+
);
136+
return;
137+
}
138+
if (summary.eventCount === 0) {
139+
// The writer ran but no events matched the timestamp filter; remove the
140+
// empty file so the user isn't left with an artifact they didn't ask for.
141+
await fs
142+
.rm(outputUri.fsPath, { force: true })
143+
.catch((err) =>
144+
logger.warn(
145+
"Failed to remove empty telemetry export",
146+
outputUri.fsPath,
147+
err,
148+
),
149+
);
150+
vscode.window.showInformationMessage(
151+
`No telemetry events matched ${range.label}.`,
152+
);
153+
return;
154+
}
155+
156+
const action = await vscode.window.showInformationMessage(
157+
`Exported ${summary.eventCount} telemetry event(s) to ${outputUri.fsPath}.`,
158+
"Reveal in File Explorer",
159+
);
160+
if (action === "Reveal in File Explorer") {
161+
try {
162+
await vscode.commands.executeCommand("revealFileInOS", outputUri);
163+
} catch (err) {
164+
// The export already succeeded; a reveal failure is informational.
165+
logger.warn("Failed to reveal exported telemetry file", err);
166+
}
167+
}
168+
}
169+
170+
async function writeExport(
171+
formatId: FormatPick["id"],
172+
outputPath: string,
173+
events: AsyncIterable<TelemetryEvent>,
174+
context: TelemetryContext,
175+
onCleanupError: (err: unknown, tempPath: string) => void,
176+
onStagingCleanupError: (err: unknown, dir: string) => void,
177+
signal: AbortSignal,
178+
): Promise<number> {
179+
if (formatId === "json") {
180+
return writeJsonArrayExport(outputPath, events, onCleanupError);
181+
}
182+
const counts = await writeOtlpZipExport(
183+
outputPath,
184+
events,
185+
context,
186+
onCleanupError,
187+
{ signal, onStagingCleanupError },
188+
);
189+
return counts.logs + counts.traces + counts.metrics;
190+
}
191+
192+
/** Wraps an async iterable to honor an AbortSignal between yields. */
193+
async function* withCancellation<T>(
194+
iterable: AsyncIterable<T>,
195+
signal: AbortSignal,
196+
): AsyncIterable<T> {
197+
for await (const item of iterable) {
198+
throwIfAborted(signal);
199+
yield item;
200+
}
201+
}
202+
203+
function throwIfAborted(signal: AbortSignal): void {
204+
if (!signal.aborted) {
205+
return;
206+
}
207+
const reason: unknown = signal.reason;
208+
throw reason instanceof Error
209+
? reason
210+
: Object.assign(new Error("Aborted"), { name: "AbortError" });
211+
}
212+
213+
async function promptDateRange(): Promise<TelemetryDateRange | undefined> {
214+
const pick = await vscode.window.showQuickPick(
215+
[
216+
...TELEMETRY_RANGE_PRESETS.map(
217+
(preset): RangePick => ({
218+
id: preset.id,
219+
label: preset.label,
220+
detail: preset.detail,
221+
}),
222+
),
223+
{
224+
id: "custom",
225+
label: "Custom range…",
226+
detail: "Choose inclusive UTC start and end dates.",
227+
} satisfies RangePick,
228+
],
229+
{
230+
title: "Export Telemetry: Date Range",
231+
placeHolder: "Select telemetry date range",
232+
ignoreFocusOut: true,
233+
},
234+
);
235+
if (!pick) {
236+
return undefined;
237+
}
238+
if (pick.id === "custom") {
239+
return promptCustomDateRange();
240+
}
241+
return createPresetDateRange(pick.id);
242+
}
243+
244+
async function promptCustomDateRange(): Promise<
245+
TelemetryDateRange | undefined
246+
> {
247+
const todayUtc = new Date().toISOString().slice(0, 10);
248+
const startDate = await vscode.window.showInputBox({
249+
title: "Export Telemetry: Custom Start Date",
250+
prompt: `Start date in UTC (YYYY-MM-DD). Today in UTC is ${todayUtc}; your local date may differ.`,
251+
value: todayUtc,
252+
validateInput: validateUtcDateInput,
253+
ignoreFocusOut: true,
254+
});
255+
if (startDate === undefined) {
256+
return undefined;
257+
}
258+
259+
const endDate = await vscode.window.showInputBox({
260+
title: "Export Telemetry: Custom End Date",
261+
prompt: `End date in UTC (YYYY-MM-DD, inclusive). Today in UTC is ${todayUtc}.`,
262+
value: startDate,
263+
validateInput: (value) => {
264+
const invalidDate = validateUtcDateInput(value);
265+
if (invalidDate !== undefined) {
266+
return invalidDate;
267+
}
268+
try {
269+
createCustomDateRange(startDate, value);
270+
return undefined;
271+
} catch (err) {
272+
return toError(err).message;
273+
}
274+
},
275+
ignoreFocusOut: true,
276+
});
277+
if (endDate === undefined) {
278+
return undefined;
279+
}
280+
281+
return createCustomDateRange(startDate, endDate);
282+
}
283+
284+
function promptFormat(): Thenable<FormatPick | undefined> {
285+
return vscode.window.showQuickPick(FORMAT_PICKS, {
286+
title: "Export Telemetry: Format",
287+
placeHolder: "Select export format",
288+
ignoreFocusOut: true,
289+
});
290+
}
291+
292+
function promptOutputUri(
293+
range: TelemetryDateRange,
294+
format: FormatPick["id"],
295+
): Thenable<vscode.Uri | undefined> {
296+
const defaultName =
297+
format === "json"
298+
? `coder-telemetry-${range.filenamePart}.json`
299+
: `coder-telemetry-${range.filenamePart}.otlp.zip`;
300+
return vscode.window.showSaveDialog({
301+
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)),
302+
filters:
303+
format === "json" ? { "JSON files": ["json"] } : { "Zip files": ["zip"] },
304+
title: "Save Telemetry Export",
305+
});
306+
}

0 commit comments

Comments
 (0)