Skip to content

Commit 4daf2f8

Browse files
committed
feat(telemetry): add export command
1 parent acadd7e commit 4daf2f8

6 files changed

Lines changed: 223 additions & 0 deletions

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,11 @@
420420
"title": "Coder: View Logs",
421421
"icon": "$(list-unordered)"
422422
},
423+
{
424+
"command": "coder.exportTelemetry",
425+
"title": "Coder: Export Telemetry",
426+
"icon": "$(save)"
427+
},
423428
{
424429
"command": "coder.openAppStatus",
425430
"title": "Open App Status",
@@ -540,6 +545,10 @@
540545
"command": "coder.viewLogs",
541546
"when": "true"
542547
},
548+
{
549+
"command": "coder.exportTelemetry",
550+
"when": "true"
551+
},
543552
{
544553
"command": "coder.openAppStatus",
545554
"when": "false"

src/commands.ts

Lines changed: 12 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,14 @@ 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+
);
363+
}
364+
353365
/**
354366
* View the logs for the currently connected workspace.
355367
*/

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
@@ -316,6 +316,10 @@ async function doActivate(
316316
void allWorkspacesProvider.fetchAndRefresh();
317317
});
318318
commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands));
319+
commandManager.register(
320+
"coder.exportTelemetry",
321+
commands.exportTelemetry.bind(commands),
322+
);
319323
commandManager.register("coder.searchMyWorkspaces", async () =>
320324
showTreeViewSearch(MY_WORKSPACES_TREE_ID),
321325
);

src/telemetry/export/command.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import * as os from "node:os";
2+
import * as path from "node:path";
3+
import * as vscode from "vscode";
4+
5+
import { toError } from "../../error/errorUtils";
6+
7+
import { listTelemetryFilesForRange, readTelemetryEvents } from "./files";
8+
import {
9+
TELEMETRY_RANGE_PRESETS,
10+
createCustomDateRange,
11+
createPresetDateRange,
12+
validateUtcDateInput,
13+
type TelemetryDateRange,
14+
type TelemetryRangePresetId,
15+
} from "./range";
16+
import { writeJsonArrayExport, writeOtlpZipExport } from "./writers";
17+
18+
import type { Logger } from "../../logging/logger";
19+
20+
interface FormatPick extends vscode.QuickPickItem {
21+
readonly id: "json" | "otlp";
22+
}
23+
24+
interface RangePick extends vscode.QuickPickItem {
25+
readonly id: TelemetryRangePresetId | "custom";
26+
}
27+
28+
const FORMAT_PICKS: readonly FormatPick[] = [
29+
{
30+
id: "json",
31+
label: "JSON array",
32+
detail: "Single JSON document for human inspection or compliance review.",
33+
},
34+
{
35+
id: "otlp",
36+
label: "OTLP/JSON zip",
37+
detail:
38+
"Zip containing logs.json, traces.json, and metrics.json for OTLP endpoints.",
39+
},
40+
];
41+
42+
export async function runExportTelemetryCommand(
43+
telemetryDir: string,
44+
logger: Logger,
45+
flushTelemetry: () => Promise<void>,
46+
): Promise<void> {
47+
try {
48+
const range = await promptDateRange();
49+
if (!range) {
50+
return;
51+
}
52+
53+
await flushTelemetry();
54+
55+
const filePaths = await listTelemetryFilesForRange(telemetryDir, range);
56+
if (filePaths.length === 0) {
57+
vscode.window.showInformationMessage(
58+
`No telemetry files found for ${range.label}.`,
59+
);
60+
return;
61+
}
62+
63+
const format = await promptFormat();
64+
if (!format) {
65+
return;
66+
}
67+
68+
const outputUri = await promptOutputUri(range, format.id);
69+
if (!outputUri) {
70+
return;
71+
}
72+
73+
const counts = await vscode.window.withProgress(
74+
{
75+
location: vscode.ProgressLocation.Notification,
76+
title: "Exporting Coder telemetry",
77+
},
78+
async () => {
79+
const events = readTelemetryEvents(filePaths, range);
80+
return format.id === "json"
81+
? writeJsonArrayExport(outputUri.fsPath, events)
82+
: writeOtlpZipExport(outputUri.fsPath, events);
83+
},
84+
);
85+
86+
const action = await vscode.window.showInformationMessage(
87+
`Exported ${counts.events} telemetry event(s) to ${outputUri.fsPath}.`,
88+
"Reveal in File Explorer",
89+
);
90+
if (action === "Reveal in File Explorer") {
91+
await vscode.commands.executeCommand("revealFileInOS", outputUri);
92+
}
93+
} catch (err) {
94+
logger.error("Telemetry export failed", err);
95+
vscode.window.showErrorMessage(
96+
`Telemetry export failed: ${toError(err).message}`,
97+
);
98+
throw err;
99+
}
100+
}
101+
102+
async function promptDateRange(): Promise<TelemetryDateRange | undefined> {
103+
const pick = await vscode.window.showQuickPick(
104+
[
105+
...TELEMETRY_RANGE_PRESETS.map(
106+
(preset): RangePick => ({
107+
id: preset.id,
108+
label: preset.label,
109+
detail: preset.detail,
110+
}),
111+
),
112+
{
113+
id: "custom",
114+
label: "Custom range…",
115+
detail: "Choose inclusive UTC start and end dates.",
116+
} satisfies RangePick,
117+
],
118+
{
119+
title: "Export Telemetry: Date Range",
120+
placeHolder: "Select telemetry date range",
121+
},
122+
);
123+
if (!pick) {
124+
return undefined;
125+
}
126+
if (pick.id === "custom") {
127+
return promptCustomDateRange();
128+
}
129+
return createPresetDateRange(pick.id);
130+
}
131+
132+
async function promptCustomDateRange(): Promise<
133+
TelemetryDateRange | undefined
134+
> {
135+
const today = new Date().toISOString().slice(0, 10);
136+
const startDate = await vscode.window.showInputBox({
137+
title: "Export Telemetry: Custom Start Date",
138+
prompt: "Start date in UTC (YYYY-MM-DD)",
139+
value: today,
140+
validateInput: validateUtcDateInput,
141+
});
142+
if (startDate === undefined) {
143+
return undefined;
144+
}
145+
146+
const endDate = await vscode.window.showInputBox({
147+
title: "Export Telemetry: Custom End Date",
148+
prompt: "End date in UTC (YYYY-MM-DD, inclusive)",
149+
value: startDate,
150+
validateInput: (value) => {
151+
const invalidDate = validateUtcDateInput(value);
152+
if (invalidDate !== undefined) {
153+
return invalidDate;
154+
}
155+
try {
156+
createCustomDateRange(startDate, value);
157+
return undefined;
158+
} catch (err) {
159+
return toError(err).message;
160+
}
161+
},
162+
});
163+
if (endDate === undefined) {
164+
return undefined;
165+
}
166+
167+
return createCustomDateRange(startDate, endDate);
168+
}
169+
170+
function promptFormat(): Thenable<FormatPick | undefined> {
171+
return vscode.window.showQuickPick(FORMAT_PICKS, {
172+
title: "Export Telemetry: Format",
173+
placeHolder: "Select export format",
174+
});
175+
}
176+
177+
function promptOutputUri(
178+
range: TelemetryDateRange,
179+
format: FormatPick["id"],
180+
): Thenable<vscode.Uri | undefined> {
181+
const defaultName =
182+
format === "json"
183+
? `coder-telemetry-${range.filenamePart}.json`
184+
: `coder-telemetry-${range.filenamePart}.otlp.zip`;
185+
return vscode.window.showSaveDialog({
186+
defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)),
187+
filters:
188+
format === "json" ? { "JSON files": ["json"] } : { "Zip files": ["zip"] },
189+
title: "Save Telemetry Export",
190+
});
191+
}

src/telemetry/service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
126126
});
127127
}
128128

129+
public async flush(): Promise<void> {
130+
await Promise.allSettled(
131+
this.sinks.map((sink) => this.#safeCall(sink, "flush")),
132+
);
133+
}
134+
129135
public async dispose(): Promise<void> {
130136
this.#configWatcher.dispose();
131137
await Promise.allSettled(

0 commit comments

Comments
 (0)