Skip to content

Commit 5439875

Browse files
committed
feat(telemetry): add export command
1 parent d9f955a commit 5439875

6 files changed

Lines changed: 239 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: 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
@@ -315,6 +315,10 @@ async function doActivate(
315315
void allWorkspacesProvider.fetchAndRefresh();
316316
});
317317
commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands));
318+
commandManager.register(
319+
"coder.exportTelemetry",
320+
commands.exportTelemetry.bind(commands),
321+
);
318322
commandManager.register("coder.searchMyWorkspaces", async () =>
319323
showTreeViewSearch(MY_WORKSPACES_TREE_ID),
320324
);

src/telemetry/export/command.ts

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

src/telemetry/service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type CallerProperties,
1414
type CallerPropertyValue,
1515
type SessionContext,
16+
type TelemetryContext,
1617
type TelemetryEvent,
1718
type TelemetryLevel,
1819
type TelemetrySink,
@@ -91,6 +92,11 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
9192
this.#deploymentUrl = url;
9293
}
9394

95+
/** Snapshot of the context every emitted event currently carries. */
96+
public getContext(): TelemetryContext {
97+
return { ...this.#session, deploymentUrl: this.#deploymentUrl };
98+
}
99+
94100
public log(
95101
eventName: string,
96102
properties: CallerProperties = {},
@@ -152,6 +158,12 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
152158
);
153159
}
154160

161+
public async flush(): Promise<void> {
162+
await Promise.allSettled(
163+
this.sinks.map((sink) => this.#safeCall(sink, "flush")),
164+
);
165+
}
166+
155167
public async dispose(): Promise<void> {
156168
this.#configWatcher.dispose();
157169
await Promise.allSettled(

0 commit comments

Comments
 (0)