diff --git a/package.json b/package.json index 5fa89c998..85c3cae3a 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 1f48d36a4..e13f3835b 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 5b30bda27..33c003519 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 dda5b8852..02b327379 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 000000000..42befcbdb --- /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 aa65485cf..65657e447 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 5b2d6ca45..42415bcb2 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 000000000..67ef91609 --- /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 45c7a2993..669b118b6 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(); + }); + }); });