diff --git a/package.json b/package.json index 1e65216691..4450d63ecc 100644 --- a/package.json +++ b/package.json @@ -420,6 +420,11 @@ "title": "Coder: View Logs", "icon": "$(list-unordered)" }, + { + "command": "coder.exportTelemetry", + "title": "Coder: Export Telemetry", + "icon": "$(save)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -540,6 +545,10 @@ "command": "coder.viewLogs", "when": "true" }, + { + "command": "coder.exportTelemetry", + "when": "true" + }, { "command": "coder.openAppStatus", "when": "false" diff --git a/src/commands.ts b/src/commands.ts index 1f48d36a40..c22287c298 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,6 +26,7 @@ import { applySettingOverrides, } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; +import { runExportTelemetryCommand } from "./telemetry/export/command"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; @@ -49,6 +50,7 @@ import type { SecretsManager } from "./core/secretsManager"; import type { DeploymentManager } from "./deployment/deploymentManager"; import type { Logger } from "./logging/logger"; import type { LoginCoordinator } from "./login/loginCoordinator"; +import type { TelemetryService } from "./telemetry/service"; import type { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; import type { DuplicateWorkspaceIpc, @@ -80,6 +82,7 @@ export class Commands { private readonly loginCoordinator: LoginCoordinator; private readonly duplicateWorkspaceIpc: DuplicateWorkspaceIpc; private readonly speedtestPanelFactory: SpeedtestPanelFactory; + private readonly telemetryService: TelemetryService; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not @@ -97,6 +100,7 @@ export class Commands { private readonly extensionClient: CoderApi, private readonly deploymentManager: DeploymentManager, ) { + this.telemetryService = serviceContainer.getTelemetryService(); this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.mementoManager = serviceContainer.getMementoManager(); @@ -350,6 +354,14 @@ export class Commands { }); } + public async exportTelemetry(): Promise { + await runExportTelemetryCommand( + this.pathResolver.getTelemetryPath(), + this.logger, + () => this.telemetryService.flush(), + ); + } + /** * View the logs for the currently connected workspace. */ diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 241183c183..21fe63b8ab 100644 --- a/src/core/commandManager.ts +++ b/src/core/commandManager.ts @@ -20,6 +20,7 @@ export const CODER_COMMAND_IDS = [ "coder.navigateToWorkspaceSettings", "coder.refreshWorkspaces", "coder.viewLogs", + "coder.exportTelemetry", "coder.searchMyWorkspaces", "coder.searchAllWorkspaces", "coder.manageCredentials", diff --git a/src/extension.ts b/src/extension.ts index 3067014c2c..ee4fe450cc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -316,6 +316,10 @@ async function doActivate( void allWorkspacesProvider.fetchAndRefresh(); }); commandManager.register("coder.viewLogs", commands.viewLogs.bind(commands)); + commandManager.register( + "coder.exportTelemetry", + commands.exportTelemetry.bind(commands), + ); commandManager.register("coder.searchMyWorkspaces", async () => showTreeViewSearch(MY_WORKSPACES_TREE_ID), ); diff --git a/src/telemetry/export/command.ts b/src/telemetry/export/command.ts new file mode 100644 index 0000000000..f0069c4ae9 --- /dev/null +++ b/src/telemetry/export/command.ts @@ -0,0 +1,191 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { toError } from "../../error/errorUtils"; + +import { listTelemetryFilesForRange, readTelemetryEvents } from "./files"; +import { + TELEMETRY_RANGE_PRESETS, + createCustomDateRange, + createPresetDateRange, + validateUtcDateInput, + type TelemetryDateRange, + type TelemetryRangePresetId, +} from "./range"; +import { writeJsonArrayExport, writeOtlpZipExport } from "./writers"; + +import type { Logger } from "../../logging/logger"; + +interface FormatPick extends vscode.QuickPickItem { + readonly id: "json" | "otlp"; +} + +interface RangePick extends vscode.QuickPickItem { + readonly id: TelemetryRangePresetId | "custom"; +} + +const FORMAT_PICKS: readonly FormatPick[] = [ + { + id: "json", + label: "JSON array", + detail: "Single JSON document for human inspection or compliance review.", + }, + { + id: "otlp", + label: "OTLP/JSON zip", + detail: + "Zip containing logs.json, traces.json, and metrics.json for OTLP endpoints.", + }, +]; + +export async function runExportTelemetryCommand( + telemetryDir: string, + logger: Logger, + flushTelemetry: () => Promise, +): Promise { + try { + const range = await promptDateRange(); + if (!range) { + return; + } + + await flushTelemetry(); + + const filePaths = await listTelemetryFilesForRange(telemetryDir, range); + if (filePaths.length === 0) { + vscode.window.showInformationMessage( + `No telemetry files found for ${range.label}.`, + ); + return; + } + + const format = await promptFormat(); + if (!format) { + return; + } + + const outputUri = await promptOutputUri(range, format.id); + if (!outputUri) { + return; + } + + const counts = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Exporting Coder telemetry", + }, + async () => { + const events = readTelemetryEvents(filePaths, range); + return format.id === "json" + ? writeJsonArrayExport(outputUri.fsPath, events) + : writeOtlpZipExport(outputUri.fsPath, events); + }, + ); + + const action = await vscode.window.showInformationMessage( + `Exported ${counts.events} telemetry event(s) to ${outputUri.fsPath}.`, + "Reveal in File Explorer", + ); + if (action === "Reveal in File Explorer") { + await vscode.commands.executeCommand("revealFileInOS", outputUri); + } + } catch (err) { + logger.error("Telemetry export failed", err); + vscode.window.showErrorMessage( + `Telemetry export failed: ${toError(err).message}`, + ); + throw err; + } +} + +async function promptDateRange(): Promise { + const pick = await vscode.window.showQuickPick( + [ + ...TELEMETRY_RANGE_PRESETS.map( + (preset): RangePick => ({ + id: preset.id, + label: preset.label, + detail: preset.detail, + }), + ), + { + id: "custom", + label: "Custom range…", + detail: "Choose inclusive UTC start and end dates.", + } satisfies RangePick, + ], + { + title: "Export Telemetry: Date Range", + placeHolder: "Select telemetry date range", + }, + ); + if (!pick) { + return undefined; + } + if (pick.id === "custom") { + return promptCustomDateRange(); + } + return createPresetDateRange(pick.id); +} + +async function promptCustomDateRange(): Promise< + TelemetryDateRange | undefined +> { + const today = new Date().toISOString().slice(0, 10); + const startDate = await vscode.window.showInputBox({ + title: "Export Telemetry: Custom Start Date", + prompt: "Start date in UTC (YYYY-MM-DD)", + value: today, + validateInput: validateUtcDateInput, + }); + if (startDate === undefined) { + return undefined; + } + + const endDate = await vscode.window.showInputBox({ + title: "Export Telemetry: Custom End Date", + prompt: "End date in UTC (YYYY-MM-DD, inclusive)", + value: startDate, + validateInput: (value) => { + const invalidDate = validateUtcDateInput(value); + if (invalidDate !== undefined) { + return invalidDate; + } + try { + createCustomDateRange(startDate, value); + return undefined; + } catch (err) { + return toError(err).message; + } + }, + }); + if (endDate === undefined) { + return undefined; + } + + return createCustomDateRange(startDate, endDate); +} + +function promptFormat(): Thenable { + return vscode.window.showQuickPick(FORMAT_PICKS, { + title: "Export Telemetry: Format", + placeHolder: "Select export format", + }); +} + +function promptOutputUri( + range: TelemetryDateRange, + format: FormatPick["id"], +): Thenable { + const defaultName = + format === "json" + ? `coder-telemetry-${range.filenamePart}.json` + : `coder-telemetry-${range.filenamePart}.otlp.zip`; + return vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters: + format === "json" ? { "JSON files": ["json"] } : { "Zip files": ["zip"] }, + title: "Save Telemetry Export", + }); +} diff --git a/src/telemetry/service.ts b/src/telemetry/service.ts index 05ac148267..47f8388535 100644 --- a/src/telemetry/service.ts +++ b/src/telemetry/service.ts @@ -126,6 +126,12 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter { }); } + public async flush(): Promise { + await Promise.allSettled( + this.sinks.map((sink) => this.#safeCall(sink, "flush")), + ); + } + public async dispose(): Promise { this.#configWatcher.dispose(); await Promise.allSettled(