From 7416865b203a1ed66422d2d1f8a317bcd7ac6e06 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 26 May 2026 23:27:38 +0300 Subject: [PATCH] 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". --- package.json | 9 + src/commands.ts | 13 + src/core/commandManager.ts | 1 + src/extension.ts | 4 + src/telemetry/export/command.ts | 283 ++++++++++++++ src/telemetry/service.ts | 14 +- test/mocks/vscode.runtime.ts | 3 + test/unit/telemetry/export/command.test.ts | 416 +++++++++++++++++++++ test/unit/telemetry/service.test.ts | 69 ++++ 9 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 src/telemetry/export/command.ts create mode 100644 test/unit/telemetry/export/command.test.ts diff --git a/package.json b/package.json index 5fa89c9985..85c3cae3a6 100644 --- a/package.json +++ b/package.json @@ -422,6 +422,11 @@ "title": "Coder: View Logs", "icon": "$(list-unordered)" }, + { + "command": "coder.exportTelemetry", + "title": "Coder: Export Telemetry", + "icon": "$(save)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -536,6 +541,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..e13f3835bb 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,15 @@ export class Commands { }); } + public async exportTelemetry(): Promise { + await runExportTelemetryCommand( + this.pathResolver.getTelemetryPath(), + this.logger, + () => this.telemetryService.flush(), + this.telemetryService.getContext(), + ); + } + /** * View the logs for the currently connected workspace. */ diff --git a/src/core/commandManager.ts b/src/core/commandManager.ts index 5b30bda277..33c0035193 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 dda5b8852f..02b3273791 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -295,6 +295,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..42befcbdb5 --- /dev/null +++ b/src/telemetry/export/command.ts @@ -0,0 +1,283 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { toError } from "../../error/errorUtils"; +import { withCancellableProgress } from "../../progress"; + +import { listTelemetryFilesForRange, streamTelemetryEvents } from "./files"; +import { + TELEMETRY_RANGE_PRESETS, + createCustomDateRange, + createPresetDateRange, + validateUtcDateInput, + type TelemetryDateRange, + type TelemetryRangePresetId, +} from "./range"; +import { writeJsonArrayExport } from "./writers/json"; +import { writeOtlpZipExport } from "./writers/otlp/writer"; + +import type { Logger } from "../../logging/logger"; +import type { TelemetryContext } from "../event"; + +interface FormatPick extends vscode.QuickPickItem { + readonly id: "json" | "otlp"; +} + +interface RangePick extends vscode.QuickPickItem { + readonly id: TelemetryRangePresetId | "custom"; +} + +interface FormatOutput { + readonly ext: string; + readonly filters: NonNullable; +} + +interface ExportSummary { + readonly filesScanned: number; + readonly eventCount: number; +} + +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.", + }, +]; + +const CUSTOM_RANGE_PICK: RangePick = { + id: "custom", + label: "Custom range…", + detail: "Choose inclusive UTC start and end dates.", +}; + +const FORMAT_OUTPUT: Record = { + json: { ext: "json", filters: { "JSON files": ["json"] } }, + otlp: { ext: "otlp.zip", filters: { "Zip files": ["zip"] } }, +}; + +export async function runExportTelemetryCommand( + telemetryDir: string, + logger: Logger, + flushTelemetry: () => Promise, + context: TelemetryContext, +): Promise { + const range = await promptDateRange(); + if (!range) return; + const format = await promptFormat(); + if (!format) return; + const outputUri = await promptSavePath(range, format.id); + if (!outputUri) return; + + const onCleanupError = cleanupWarner( + logger, + "Failed to delete telemetry export temp file", + ); + const onStagingCleanupError = cleanupWarner( + logger, + "Failed to delete telemetry export staging directory", + ); + + // Flush + list run inside the progress callback so the on-disk snapshot + // is taken right before streaming and the user can cancel a long flush. + const result = await withCancellableProgress( + async ({ signal, progress }): Promise => { + progress.report({ message: "Flushing buffered events..." }); + await flushTelemetry(); + throwIfAborted(signal); + + progress.report({ message: "Locating telemetry files..." }); + const filePaths = await listTelemetryFilesForRange(telemetryDir, range); + if (filePaths.length === 0) { + return { filesScanned: 0, eventCount: 0 }; + } + + progress.report({ message: "Writing export..." }); + const events = (async function* () { + for await (const event of streamTelemetryEvents(filePaths, range)) { + throwIfAborted(signal); + yield event; + } + })(); + + let eventCount: number; + if (format.id === "json") { + eventCount = await writeJsonArrayExport( + outputUri.fsPath, + events, + onCleanupError, + ); + } else { + const counts = await writeOtlpZipExport( + outputUri.fsPath, + events, + context, + { + signal, + onTempCleanupError: onCleanupError, + onStagingCleanupError, + }, + ); + eventCount = counts.logs + counts.traces + counts.metrics; + } + return { filesScanned: filePaths.length, eventCount }; + }, + { + location: vscode.ProgressLocation.Notification, + title: "Exporting Coder telemetry", + cancellable: true, + }, + ); + + if (!result.ok) { + if (result.cancelled) return; + logger.error("Telemetry export failed", result.error); + vscode.window.showErrorMessage( + `Telemetry export failed: ${toError(result.error).message}`, + ); + return; + } + + const { filesScanned, eventCount } = result.value; + if (filesScanned === 0) { + vscode.window.showInformationMessage( + `No telemetry files found for ${range.label}.`, + ); + return; + } + if (eventCount === 0) { + await notifyNoEventsMatched(range, outputUri, logger); + return; + } + await notifyExportSuccess(outputUri, eventCount, logger); +} + +function cleanupWarner( + logger: Logger, + message: string, +): (err: unknown, target: string) => void { + return (err, target) => logger.warn(message, target, err); +} + +async function notifyExportSuccess( + outputUri: vscode.Uri, + eventCount: number, + logger: Logger, +): Promise { + const action = await vscode.window.showInformationMessage( + `Exported ${eventCount} telemetry event(s) to ${outputUri.fsPath}.`, + "Reveal in File Explorer", + ); + if (action !== "Reveal in File Explorer") return; + try { + await vscode.commands.executeCommand("revealFileInOS", outputUri); + } catch (err) { + // The export already succeeded; a reveal failure is informational. + logger.warn("Failed to reveal exported telemetry file", err); + } +} + +async function notifyNoEventsMatched( + range: TelemetryDateRange, + outputUri: vscode.Uri, + logger: Logger, +): Promise { + // Remove the empty file the writer just created so the user isn't left + // with an unwanted artifact. + await fs + .rm(outputUri.fsPath, { force: true }) + .catch((err) => + logger.warn( + "Failed to remove empty telemetry export", + outputUri.fsPath, + err, + ), + ); + vscode.window.showInformationMessage( + `No telemetry events matched ${range.label}.`, + ); +} + +function throwIfAborted(signal: AbortSignal): void { + if (!signal.aborted) return; + const reason: unknown = signal.reason; + throw reason instanceof Error + ? reason + : Object.assign(new Error("Aborted"), { name: "AbortError" }); +} + +async function promptDateRange(): Promise { + const pick = await vscode.window.showQuickPick( + [...TELEMETRY_RANGE_PRESETS, CUSTOM_RANGE_PICK], + { + title: "Export Telemetry: Date Range", + placeHolder: "Select telemetry date range", + ignoreFocusOut: true, + }, + ); + if (!pick) return undefined; + if (pick.id === "custom") return promptCustomDateRange(); + return createPresetDateRange(pick.id); +} + +async function promptCustomDateRange(): Promise< + TelemetryDateRange | undefined +> { + const todayUtc = 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). Today in UTC is ${todayUtc}; your local date may differ.`, + value: todayUtc, + validateInput: validateUtcDateInput, + ignoreFocusOut: true, + }); + 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). Today in UTC is ${todayUtc}.`, + value: startDate, + validateInput: (value) => { + const invalidDate = validateUtcDateInput(value); + if (invalidDate !== undefined) return invalidDate; + // YYYY-MM-DD strings sort lexicographically as calendar dates. + if (value < startDate) { + return "End date must be on or after start date."; + } + return undefined; + }, + ignoreFocusOut: true, + }); + 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", + ignoreFocusOut: true, + }); +} + +function promptSavePath( + range: TelemetryDateRange, + format: FormatPick["id"], +): Thenable { + const { ext, filters } = FORMAT_OUTPUT[format]; + const defaultName = `coder-telemetry-${range.filenamePart}.${ext}`; + return vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters, + title: "Save Telemetry Export", + }); +} diff --git a/src/telemetry/service.ts b/src/telemetry/service.ts index aa65485cf1..65657e447d 100644 --- a/src/telemetry/service.ts +++ b/src/telemetry/service.ts @@ -13,6 +13,7 @@ import { type CallerProperties, type CallerPropertyValue, type SessionContext, + type TelemetryContext, type TelemetryEvent, type TelemetryLevel, type TelemetrySink, @@ -91,6 +92,11 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter { this.#deploymentUrl = url; } + /** Snapshot of the context every emitted event currently carries. */ + public getContext(): TelemetryContext { + return { ...this.#session, deploymentUrl: this.#deploymentUrl }; + } + public log( eventName: string, properties: CallerProperties = {}, @@ -152,6 +158,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( @@ -352,7 +364,7 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter { eventName, timestamp: new Date().toISOString(), eventSequence: this.#nextSequence++, - context: { ...this.#session, deploymentUrl: this.#deploymentUrl }, + context: this.getContext(), properties: { ...properties }, measurements: { ...measurements }, ...(traceId !== undefined && { traceId }), diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 5b2d6ca45b..42415bcb24 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -70,6 +70,9 @@ export class Uri { public scheme: string, public path: string, ) {} + get fsPath(): string { + return this.path; + } static file(p: string) { return new Uri("file", p); } diff --git a/test/unit/telemetry/export/command.test.ts b/test/unit/telemetry/export/command.test.ts new file mode 100644 index 0000000000..67ef916098 --- /dev/null +++ b/test/unit/telemetry/export/command.test.ts @@ -0,0 +1,416 @@ +import * as fsp from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { runExportTelemetryCommand } from "@/telemetry/export/command"; +import * as files from "@/telemetry/export/files"; +import * as jsonWriter from "@/telemetry/export/writers/json"; +import * as otlpWriter from "@/telemetry/export/writers/otlp/writer"; + +import { asyncIterable } from "../../../mocks/asyncIterable"; +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; +import { createMockLogger } from "../../../mocks/testHelpers"; + +import type { Logger } from "@/logging/logger"; +import type { TelemetryEvent } from "@/telemetry/event"; + +vi.mock("@/telemetry/export/files", () => ({ + listTelemetryFilesForRange: vi.fn(), + streamTelemetryEvents: vi.fn(), +})); + +vi.mock("@/telemetry/export/writers/json", () => ({ + writeJsonArrayExport: vi.fn(), +})); + +vi.mock("@/telemetry/export/writers/otlp/writer", () => ({ + writeOtlpZipExport: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + rm: vi.fn(() => Promise.resolve()), +})); + +const TELEMETRY_DIR = "/tmp/telemetry"; +const OUTPUT_PATH = "/tmp/coder-telemetry.json"; +const OUTPUT_URI = vscode.Uri.file(OUTPUT_PATH); +const OTLP_OUTPUT_URI = vscode.Uri.file("/tmp/coder-telemetry.otlp.zip"); +const TELEMETRY_FILE_PATH = "/tmp/telemetry/file.jsonl"; +const CUSTOM_RANGE_PICK = { id: "custom", label: "Custom range…" }; + +const makeEvent = createTelemetryEventFactory(); +const { context } = makeEvent(); + +interface ResolvedPrompts { + rangePick: { id: string; label: string } | undefined; + customStart: string | undefined; + customEnd: string | undefined; + formatPick: { id: "json" | "otlp"; label: string } | undefined; + saveDialog: vscode.Uri | undefined; + infoResponse: string | undefined; +} +type PromptResponses = Partial; + +const DEFAULT_PROMPT_RESPONSES: ResolvedPrompts = { + rangePick: { id: "last24Hours", label: "Last 24 hours" }, + customStart: undefined, + customEnd: undefined, + formatPick: { id: "json", label: "JSON array" }, + saveDialog: OUTPUT_URI, + infoResponse: undefined, +}; + +function mockProgress(opts: { cancelImmediately?: boolean } = {}): void { + vi.mocked(vscode.window.withProgress).mockImplementation( + async (_opts, task) => { + const progress = { report: vi.fn() }; + const token: vscode.CancellationToken = { + isCancellationRequested: opts.cancelImmediately ?? false, + onCancellationRequested: vi.fn((listener: (e: unknown) => void) => { + if (opts.cancelImmediately) listener(undefined); + return { dispose: vi.fn() }; + }), + }; + return task(progress, token); + }, + ); +} + +function mockPrompts(responses: PromptResponses = {}): void { + // Resets every prompt mock so an in-test override fully replaces what + // beforeEach queued. + const merged = { ...DEFAULT_PROMPT_RESPONSES, ...responses }; + + vi.mocked(vscode.window.showQuickPick) + .mockReset() + .mockResolvedValueOnce(merged.rangePick) + .mockResolvedValueOnce(merged.formatPick); + + const inputBox = vi.mocked(vscode.window.showInputBox).mockReset(); + if (merged.customStart !== undefined) { + inputBox.mockResolvedValueOnce(merged.customStart); + } + if (merged.customEnd !== undefined) { + inputBox.mockResolvedValueOnce(merged.customEnd); + } + + vi.mocked(vscode.window.showSaveDialog) + .mockReset() + .mockResolvedValue(merged.saveDialog); + vi.mocked(vscode.window.showInformationMessage) + .mockReset() + .mockResolvedValue(merged.infoResponse as never); + vi.mocked(vscode.window.showErrorMessage) + .mockReset() + .mockResolvedValue(undefined); + vi.mocked(vscode.commands.executeCommand) + .mockReset() + .mockResolvedValue(undefined); +} + +function mockSourceFiles( + events: readonly TelemetryEvent[] = [makeEvent()], + filePaths: readonly string[] = [TELEMETRY_FILE_PATH], +): void { + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([...filePaths]); + vi.mocked(files.streamTelemetryEvents).mockReturnValue(asyncIterable(events)); +} + +function mockJsonWriter(eventCount: number): void { + vi.mocked(jsonWriter.writeJsonArrayExport).mockResolvedValue(eventCount); +} + +function mockOtlpWriter(counts: { + logs: number; + traces: number; + metrics: number; +}): void { + vi.mocked(otlpWriter.writeOtlpZipExport).mockResolvedValue(counts); +} + +describe("runExportTelemetryCommand", () => { + let logger: Logger; + let flushTelemetry: ReturnType Promise>>; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(fsp.rm).mockResolvedValue(undefined); + logger = createMockLogger(); + flushTelemetry = vi.fn<() => Promise>(() => Promise.resolve()); + mockProgress(); + mockPrompts(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function run(): Promise { + await runExportTelemetryCommand( + TELEMETRY_DIR, + logger, + flushTelemetry, + context, + ); + } + + describe("cancellation", () => { + it.each<{ scenario: string; overrides: PromptResponses }>([ + { scenario: "date-range pick", overrides: { rangePick: undefined } }, + { scenario: "format pick", overrides: { formatPick: undefined } }, + { scenario: "save dialog", overrides: { saveDialog: undefined } }, + ])( + "returns silently when the $scenario is cancelled", + async ({ overrides }) => { + mockPrompts(overrides); + + await run(); + + expect(flushTelemetry).not.toHaveBeenCalled(); + expect(vscode.window.withProgress).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }, + ); + + it("returns silently when cancellation fires during the export", async () => { + mockProgress({ cancelImmediately: true }); + + await run(); + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe("flush and listing", () => { + it("flushes before listing telemetry files", async () => { + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([]); + + await run(); + + const [flushOrder] = flushTelemetry.mock.invocationCallOrder; + const [listOrder] = vi.mocked(files.listTelemetryFilesForRange).mock + .invocationCallOrder; + expect(flushOrder).toBeLessThan(listOrder); + }); + }); + + describe("empty handling", () => { + it("shows 'no files' notification when listing returns []", async () => { + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([]); + + await run(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("No telemetry files found"), + ); + expect(jsonWriter.writeJsonArrayExport).not.toHaveBeenCalled(); + expect(otlpWriter.writeOtlpZipExport).not.toHaveBeenCalled(); + expect(fsp.rm).not.toHaveBeenCalled(); + }); + + it("removes the empty file and notifies when no events match", async () => { + mockSourceFiles([]); + mockJsonWriter(0); + + await run(); + + expect(fsp.rm).toHaveBeenCalledWith(OUTPUT_PATH, { force: true }); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("No telemetry events matched"), + ); + }); + + it("warns but proceeds when removing the empty file fails", async () => { + mockSourceFiles([]); + mockJsonWriter(0); + vi.mocked(fsp.rm).mockRejectedValue(new Error("EACCES")); + + await run(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to remove empty"), + OUTPUT_PATH, + expect.any(Error), + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("No telemetry events matched"), + ); + }); + }); + + describe("JSON export", () => { + beforeEach(() => { + mockSourceFiles([makeEvent(), makeEvent()]); + mockJsonWriter(2); + }); + + it("shows success notification with the event count and the path", async () => { + await run(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + `Exported 2 telemetry event(s) to ${OUTPUT_PATH}.`, + "Reveal in File Explorer", + ); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + expect(fsp.rm).not.toHaveBeenCalled(); + }); + + it("invokes revealFileInOS when the user clicks the action", async () => { + mockPrompts({ infoResponse: "Reveal in File Explorer" }); + + await run(); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "revealFileInOS", + OUTPUT_URI, + ); + }); + + it("does NOT invoke revealFileInOS when the user dismisses the toast", async () => { + // Default infoResponse is undefined, so no reveal expected. + await run(); + + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it("logs and swallows a reveal failure without reporting export failure", async () => { + mockPrompts({ infoResponse: "Reveal in File Explorer" }); + vi.mocked(vscode.commands.executeCommand).mockRejectedValue( + new Error("revealFileInOS not found"), + ); + + await run(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to reveal"), + expect.any(Error), + ); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + }); + + describe("OTLP export", () => { + beforeEach(() => { + mockPrompts({ + formatPick: { id: "otlp", label: "OTLP/JSON zip" }, + saveDialog: OTLP_OUTPUT_URI, + }); + mockSourceFiles([makeEvent()]); + mockOtlpWriter({ logs: 5, traces: 3, metrics: 1 }); + }); + + it("sums logs/traces/metrics counts for the success notification", async () => { + await run(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("Exported 9 telemetry event(s)"), + "Reveal in File Explorer", + ); + }); + + it("threads the AbortSignal and cleanup callbacks to the OTLP writer", async () => { + await run(); + + expect(otlpWriter.writeOtlpZipExport).toHaveBeenCalledWith( + OTLP_OUTPUT_URI.fsPath, + expect.anything(), + context, + expect.objectContaining({ + signal: expect.any(AbortSignal), + onTempCleanupError: expect.any(Function), + onStagingCleanupError: expect.any(Function), + }), + ); + }); + }); + + describe("writer failure", () => { + it("shows an error notification without re-throwing", async () => { + mockSourceFiles([makeEvent()]); + vi.mocked(jsonWriter.writeJsonArrayExport).mockRejectedValue( + new Error("disk full"), + ); + + await expect(run()).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledWith( + "Telemetry export failed", + expect.any(Error), + ); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining("Telemetry export failed"), + ); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + }); + + describe("custom date range", () => { + it("threads start and end dates through createCustomDateRange", async () => { + mockPrompts({ + rangePick: CUSTOM_RANGE_PICK, + customStart: "2026-01-01", + customEnd: "2026-01-31", + }); + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([]); + + await run(); + + expect(files.listTelemetryFilesForRange).toHaveBeenCalledWith( + TELEMETRY_DIR, + expect.objectContaining({ + filenamePart: "2026-01-01_to_2026-01-31", + }), + ); + }); + + it.each<{ scenario: string; overrides: PromptResponses }>([ + { + scenario: "start date", + overrides: { + rangePick: CUSTOM_RANGE_PICK, + customStart: undefined, + }, + }, + { + scenario: "end date", + overrides: { + rangePick: CUSTOM_RANGE_PICK, + customStart: "2026-01-01", + customEnd: undefined, + }, + }, + ])( + "aborts when the custom $scenario is cancelled", + async ({ overrides }) => { + mockPrompts(overrides); + + await run(); + + expect(files.listTelemetryFilesForRange).not.toHaveBeenCalled(); + }, + ); + }); + + describe("prompt UX", () => { + it("sets ignoreFocusOut on every prompt so focus loss does not silently abort", async () => { + mockPrompts({ + rangePick: CUSTOM_RANGE_PICK, + customStart: "2026-01-01", + customEnd: "2026-01-31", + }); + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([]); + + await run(); + + for (const [, opts] of vi.mocked(vscode.window.showQuickPick).mock + .calls) { + expect(opts).toMatchObject({ ignoreFocusOut: true }); + } + for (const [opts] of vi.mocked(vscode.window.showInputBox).mock.calls) { + expect(opts).toMatchObject({ ignoreFocusOut: true }); + } + }); + }); +}); diff --git a/test/unit/telemetry/service.test.ts b/test/unit/telemetry/service.test.ts index 45c7a2993d..669b118b64 100644 --- a/test/unit/telemetry/service.test.ts +++ b/test/unit/telemetry/service.test.ts @@ -746,4 +746,73 @@ describe("TelemetryService", () => { expect(good.dispose).toHaveBeenCalled(); }); }); + + describe("getContext", () => { + it("returns the session plus the current deploymentUrl", () => { + h.service.setDeploymentUrl("https://coder.example.com"); + expect(h.service.getContext()).toEqual({ + extensionVersion: "1.2.3-test", + machineId: "test-machine-id", + sessionId: TEST_SESSION_ID, + osType: expect.any(String), + osVersion: expect.any(String), + hostArch: expect.any(String), + platformName: expect.any(String), + platformVersion: expect.any(String), + deploymentUrl: "https://coder.example.com", + }); + }); + + it("matches the context attached to emitted events", () => { + h.service.setDeploymentUrl("https://coder.example.com"); + h.service.log("activation"); + expect(h.service.getContext()).toEqual(h.sink.events[0].context); + }); + + it("returns a fresh object each call so callers can't mutate internal state", () => { + const a = h.service.getContext(); + const b = h.service.getContext(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + + it("reflects setDeploymentUrl changes between calls", () => { + h.service.setDeploymentUrl("a"); + expect(h.service.getContext().deploymentUrl).toBe("a"); + h.service.setDeploymentUrl("b"); + expect(h.service.getContext().deploymentUrl).toBe("b"); + }); + }); + + describe("flush", () => { + it("flushes every sink", async () => { + const second = new TestSink("second"); + const service = makeService([h.sink, second]); + + await service.flush(); + + expect(h.sink.flush).toHaveBeenCalledTimes(1); + expect(second.flush).toHaveBeenCalledTimes(1); + }); + + it("resolves even when a sink rejects", async () => { + const bad: TelemetrySink = { + name: "bad", + minLevel: "local", + write: () => {}, + flush: () => Promise.reject(new Error("flush failed")), + dispose: () => Promise.resolve(), + }; + const good = new TestSink("good"); + const service = makeService([bad, good]); + + await expect(service.flush()).resolves.toBeUndefined(); + expect(good.flush).toHaveBeenCalled(); + }); + + it("does not dispose sinks", async () => { + await h.service.flush(); + expect(h.sink.dispose).not.toHaveBeenCalled(); + }); + }); });