diff --git a/package.json b/package.json index 6e27a11b5..03cf037b0 100644 --- a/package.json +++ b/package.json @@ -436,6 +436,11 @@ "title": "Coder: View Logs", "icon": "$(list-unordered)" }, + { + "command": "coder.exportTelemetry", + "title": "Coder: Export Telemetry", + "icon": "$(save)" + }, { "command": "coder.openAppStatus", "title": "Open App Status", @@ -550,6 +555,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 735146562..6dc95290d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -26,6 +26,7 @@ import { } from "./remote/sshOverrides"; import { resolveCliAuth } from "./settings/cli"; import { appendVsCodeLogs } from "./supportBundle/appendVsCodeLogs"; +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(); @@ -351,6 +355,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/error/errorUtils.ts b/src/error/errorUtils.ts index d70934cb8..b4f3b3c15 100644 --- a/src/error/errorUtils.ts +++ b/src/error/errorUtils.ts @@ -8,6 +8,18 @@ export function isAbortError(error: unknown): error is Error { return error instanceof Error && error.name === "AbortError"; } +/** + * Like AbortSignal.throwIfAborted() but coerces non-Error reasons (e.g. the + * default DOMException) to a named AbortError so isAbortError matches them. + */ +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + const reason: unknown = signal.reason; + throw reason instanceof Error + ? reason + : Object.assign(new Error("Aborted"), { name: "AbortError" }); +} + // getErrorDetail is copied from coder/site, but changes the default return. export const getErrorDetail = (error: unknown): string | undefined | null => { if (isApiError(error)) { diff --git a/src/extension.ts b/src/extension.ts index 76adf8f58..c8e7fb8f3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -321,6 +321,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..94cb66582 --- /dev/null +++ b/src/telemetry/export/command.ts @@ -0,0 +1,124 @@ +import * as vscode from "vscode"; + +import { toError } from "../../error/errorUtils"; +import { + withCancellableProgress, + type ProgressContext, + type ProgressResult, +} from "../../progress"; + +import { + collectTelemetryExport, + type ExportRequest, + type ExportRuntime, +} from "./pipeline"; +import { promptForExport, type ExportChoice } from "./prompts"; +import { createExportWriter } from "./writers"; + +import type { Logger } from "../../logging/logger"; +import type { TelemetryContext } from "../event"; + +const REVEAL_ACTION = "Reveal in File Explorer"; + +const PROGRESS_OPTIONS = { + location: vscode.ProgressLocation.Notification, + title: "Exporting Coder telemetry", + cancellable: true, +} as const; + +export async function runExportTelemetryCommand( + telemetryDir: string, + logger: Logger, + flushTelemetry: () => Promise, + context: TelemetryContext, +): Promise { + const choice = await promptForExport(); + if (!choice) { + return; + } + + const request: ExportRequest = { + telemetryDir, + range: choice.range, + outputPath: choice.outputPath, + writer: createExportWriter(choice.format, context), + }; + const result = await withCancellableProgress( + (ctx) => + collectTelemetryExport( + request, + exportRuntime(ctx, flushTelemetry, logger), + ), + PROGRESS_OPTIONS, + ); + + await reportOutcome(result, choice, logger); +} + +/** Wires the pipeline's host hooks to the progress UI and the logger. */ +function exportRuntime( + { progress, signal }: ProgressContext, + flushTelemetry: () => Promise, + logger: Logger, +): ExportRuntime { + return { + signal, + flushTelemetry, + report: (message) => progress.report({ message }), + onCleanupError: (err, target) => + logger.warn("Failed to clean up after telemetry export", target, err), + }; +} + +/** Turns the export result into the matching user-facing notification. */ +async function reportOutcome( + result: ProgressResult, + choice: ExportChoice, + logger: Logger, +): Promise { + if (!result.ok) { + if (result.cancelled) { + return; + } + logger.error("Telemetry export failed", result.error); + void vscode.window.showErrorMessage( + `Telemetry export failed: ${toError(result.error).message}`, + ); + return; + } + + const eventCount = result.value; + if (eventCount === 0) { + void vscode.window.showInformationMessage( + `No telemetry events found for ${choice.range.label}.`, + ); + return; + } + await notifyExportSucceeded(choice.outputPath, eventCount, logger); +} + +async function notifyExportSucceeded( + outputPath: string, + eventCount: number, + logger: Logger, +): Promise { + const action = await vscode.window.showInformationMessage( + `Exported ${formatEventCount(eventCount)} to ${outputPath}.`, + REVEAL_ACTION, + ); + if (action !== REVEAL_ACTION) { + return; + } + try { + await vscode.commands.executeCommand( + "revealFileInOS", + vscode.Uri.file(outputPath), + ); + } catch (err) { + logger.warn("Failed to reveal exported telemetry file", err); + } +} + +function formatEventCount(count: number): string { + return `${count} telemetry ${count === 1 ? "event" : "events"}`; +} diff --git a/src/telemetry/export/pipeline.ts b/src/telemetry/export/pipeline.ts new file mode 100644 index 000000000..4d13bc48e --- /dev/null +++ b/src/telemetry/export/pipeline.ts @@ -0,0 +1,89 @@ +import * as fs from "node:fs/promises"; + +import { throwIfAborted } from "../../error/errorUtils"; + +import { listTelemetryFilesForRange, streamTelemetryEvents } from "./files"; + +import type { TelemetryEvent } from "../event"; + +import type { TelemetryDateRange } from "./range"; +import type { ExportWriter } from "./writers/types"; + +export interface ExportRequest { + readonly telemetryDir: string; + readonly range: TelemetryDateRange; + readonly outputPath: string; + readonly writer: ExportWriter; +} + +/** + * Host hooks the export needs: cancellation, flush, progress, and a cleanup + * callback. Free of VS Code types so the pipeline is testable without a UI. + */ +export interface ExportRuntime { + readonly signal: AbortSignal; + readonly flushTelemetry: () => Promise; + readonly report: (message: string) => void; + /** A temp file, staging dir, or empty export could not be removed (caller logs). */ + readonly onCleanupError: (err: unknown, target: string) => void; +} + +/** + * Flushes telemetry, then streams every event in the range to `outputPath`. + * Returns the number written; an export matching nothing leaves no file + * behind. Throws on cancellation or write failure. + */ +export async function collectTelemetryExport( + request: ExportRequest, + runtime: ExportRuntime, +): Promise { + runtime.report("Flushing buffered events..."); + await runtime.flushTelemetry(); + throwIfAborted(runtime.signal); + + runtime.report("Locating telemetry files..."); + const filePaths = await listTelemetryFilesForRange( + request.telemetryDir, + request.range, + ); + if (filePaths.length === 0) { + return 0; + } + + runtime.report("Writing export..."); + const events = abortable( + streamTelemetryEvents(filePaths, request.range), + runtime.signal, + ); + const eventCount = await request.writer(request.outputPath, events, { + signal: runtime.signal, + onCleanupError: runtime.onCleanupError, + }); + if (eventCount === 0) { + await removeEmptyExport(request.outputPath, runtime.onCleanupError); + } + return eventCount; +} + +/** Removes the file a writer produced for an export that matched no events. */ +async function removeEmptyExport( + outputPath: string, + onCleanupError: (err: unknown, target: string) => void, +): Promise { + try { + await fs.rm(outputPath, { force: true }); + } catch (err) { + onCleanupError(err, outputPath); + } +} + +/** Re-yields `events`, checking for cancellation before each one. */ +async function* abortable( + events: AsyncIterable, + signal: AbortSignal, +): AsyncIterable { + for await (const event of events) { + throwIfAborted(signal); + yield event; + } +} diff --git a/src/telemetry/export/prompts.ts b/src/telemetry/export/prompts.ts new file mode 100644 index 000000000..249e039f3 --- /dev/null +++ b/src/telemetry/export/prompts.ts @@ -0,0 +1,156 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { toUtcDateString, validateUtcDateInput } from "../../util/date"; + +import { + TELEMETRY_RANGE_PRESETS, + createCustomDateRange, + createPresetDateRange, + type TelemetryDateRange, + type TelemetryRangePresetId, +} from "./range"; + +import type { ExportFormat } from "./writers/types"; + +/** What the user chose: which range, in which format, written where. */ +export interface ExportChoice { + readonly range: TelemetryDateRange; + readonly format: ExportFormat; + readonly outputPath: string; +} + +interface FormatPick extends vscode.QuickPickItem { + readonly id: ExportFormat; +} + +interface RangePick extends vscode.QuickPickItem { + readonly id: TelemetryRangePresetId | "custom"; +} + +interface FileFormat { + readonly ext: string; + readonly filters: NonNullable; +} + +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 FILE_FILTERS: Record = { + json: { ext: "json", filters: { "JSON files": ["json"] } }, + otlp: { ext: "otlp.zip", filters: { "Zip files": ["zip"] } }, +}; + +/** Runs the range, format, then destination prompts; undefined on any cancel. */ +export async function promptForExport(): Promise { + const range = await promptDateRange(); + if (!range) { + return undefined; + } + const format = await promptFormat(); + if (!format) { + return undefined; + } + const outputPath = await promptSavePath(range, format); + if (!outputPath) { + return undefined; + } + return { range, format, outputPath }; +} + +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; + } + return pick.id === "custom" + ? promptCustomDateRange() + : createPresetDateRange(pick.id); +} + +async function promptCustomDateRange(): Promise< + TelemetryDateRange | undefined +> { + const todayUtc = toUtcDateString(new Date()); + 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) => + validateUtcDateInput(value) ?? + validateEndOnOrAfterStart(value, startDate), + ignoreFocusOut: true, + }); + if (endDate === undefined) { + return undefined; + } + + return createCustomDateRange(startDate, endDate); +} + +/** YYYY-MM-DD strings sort lexicographically as calendar dates. */ +function validateEndOnOrAfterStart( + end: string, + start: string, +): string | undefined { + return end < start ? "End date must be on or after start date." : undefined; +} + +async function promptFormat(): Promise { + const pick = await vscode.window.showQuickPick(FORMAT_PICKS, { + title: "Export Telemetry: Format", + placeHolder: "Select export format", + ignoreFocusOut: true, + }); + return pick?.id; +} + +async function promptSavePath( + range: TelemetryDateRange, + format: ExportFormat, +): Promise { + const { ext, filters } = FILE_FILTERS[format]; + const defaultName = `coder-telemetry-${range.filenamePart}.${ext}`; + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters, + title: "Save Telemetry Export", + }); + return uri?.fsPath; +} diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts index 0b350d486..988d719ff 100644 --- a/src/telemetry/export/range.ts +++ b/src/telemetry/export/range.ts @@ -1,7 +1,4 @@ -import { z } from "zod"; - -const DAY_MS = 24 * 60 * 60 * 1000; -const UtcDateSchema = z.iso.date(); +import { DAY_MS, parseUtcDate, toUtcDateString } from "../../util/date"; /** * Half-open UTC window `[startMs, endMs)` used to filter telemetry. Either @@ -92,16 +89,6 @@ export function createCustomDateRange( }; } -/** User-facing error string if `value` isn't a UTC date, else `undefined`. */ -export function validateUtcDateInput(value: string): string | undefined { - if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { - return "Use YYYY-MM-DD."; - } - return UtcDateSchema.safeParse(value).success - ? undefined - : "Enter a valid calendar date."; -} - /** Parses a telemetry ISO timestamp to epoch ms, throwing on unparseable input. */ export function parseTelemetryTimestampMs(timestamp: string): number { const ms = Date.parse(timestamp); @@ -132,24 +119,15 @@ export function fileDateCanContainRangeEvent( range: TelemetryDateRange, ): boolean { const startDate = - range.startMs === undefined ? undefined : utcDateString(range.startMs); + range.startMs === undefined + ? undefined + : toUtcDateString(new Date(range.startMs)); const endDate = - range.endMs === undefined ? undefined : utcDateString(range.endMs - 1); + range.endMs === undefined + ? undefined + : toUtcDateString(new Date(range.endMs - 1)); return ( (startDate === undefined || date >= startDate) && (endDate === undefined || date <= endDate) ); } - -function parseUtcDate(value: string): number { - const error = validateUtcDateInput(value); - if (error !== undefined) { - throw new Error(`Invalid date '${value}': ${error}`); - } - const [y, m, d] = value.split("-").map(Number); - return Date.UTC(y, m - 1, d); -} - -function utcDateString(ms: number): string { - return new Date(ms).toISOString().slice(0, 10); -} diff --git a/src/telemetry/export/writers/index.ts b/src/telemetry/export/writers/index.ts new file mode 100644 index 000000000..836ab9ac9 --- /dev/null +++ b/src/telemetry/export/writers/index.ts @@ -0,0 +1,22 @@ +import { writeJsonArrayExport } from "./json"; +import { writeOtlpZipExport } from "./otlp/writer"; + +import type { TelemetryContext } from "../../event"; + +import type { ExportFormat, ExportWriter } from "./types"; + +export type { ExportFormat, ExportWriteOptions, ExportWriter } from "./types"; + +/** Picks the writer for `format`, binding the context the OTLP writer needs. */ +export function createExportWriter( + format: ExportFormat, + context: TelemetryContext, +): ExportWriter { + if (format === "json") { + return writeJsonArrayExport; + } + return (outputPath, events, options) => + writeOtlpZipExport(outputPath, events, context, options).then( + (counts) => counts.logs + counts.traces + counts.metrics, + ); +} diff --git a/src/telemetry/export/writers/json.ts b/src/telemetry/export/writers/json.ts index 832b34e8e..d9b8ddb1f 100644 --- a/src/telemetry/export/writers/json.ts +++ b/src/telemetry/export/writers/json.ts @@ -7,16 +7,17 @@ import { serializeTelemetryEvent } from "../../wireFormat"; import type { TelemetryEvent } from "../../event"; +import type { ExportWriteOptions } from "./types"; + /** - * Streams `events` as a JSON array to `outputPath` via a temp file and - * atomic rename. Returns the number of events written. `onCleanupError` - * is invoked if removing the temp file after a failed write itself fails - * (typically a Windows lock); callers are expected to log it. + * Streams `events` as a JSON array to `outputPath` via a temp file and atomic + * rename. Returns the number of events written; empty input writes a valid + * `[]`. `options.signal` is unused (callers cancel upstream of the stream). */ export async function writeJsonArrayExport( outputPath: string, events: AsyncIterable, - onCleanupError: (err: unknown, tempPath: string) => void, + options: ExportWriteOptions = {}, ): Promise { let count = 0; async function* chunks(): AsyncGenerator { @@ -36,7 +37,7 @@ export async function writeJsonArrayExport( createWriteStream(tempPath, { encoding: "utf8" }), ); }, - onCleanupError, + options.onCleanupError, ); return count; } diff --git a/src/telemetry/export/writers/otlp/writer.ts b/src/telemetry/export/writers/otlp/writer.ts index 698550001..f78da6f52 100644 --- a/src/telemetry/export/writers/otlp/writer.ts +++ b/src/telemetry/export/writers/otlp/writer.ts @@ -4,7 +4,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { isAbortError, toError, wrapError } from "../../../../error/errorUtils"; +import { + isAbortError, + throwIfAborted, + toError, + wrapError, +} from "../../../../error/errorUtils"; import { writeAtomically } from "../../../../util/fs"; import { describeMetricEvent } from "../../metrics"; @@ -24,6 +29,7 @@ import { } from "./records"; import type { TelemetryContext, TelemetryEvent } from "../../../event"; +import type { ExportWriteOptions } from "../types"; /** Event totals by signal — a metric event with all records suppressed still counts as one. */ export interface OtlpExportCounts { @@ -32,13 +38,6 @@ export interface OtlpExportCounts { readonly metrics: number; } -export interface OtlpWriteOptions { - readonly signal?: AbortSignal; - readonly onTempCleanupError?: (err: unknown, tempPath: string) => void; - /** Fires on either success or failure path so cleanup errors never mask the export outcome. */ - readonly onStagingCleanupError?: (err: unknown, dir: string) => void; -} - interface Channel { file: EnvelopeFile; count: number; @@ -58,7 +57,7 @@ export async function writeOtlpZipExport( outputPath: string, events: AsyncIterable, context: TelemetryContext, - options: OtlpWriteOptions = {}, + options: ExportWriteOptions = {}, ): Promise { throwIfAborted(options.signal); return writeAtomically( @@ -77,20 +76,16 @@ export async function writeOtlpZipExport( ); await packZip(zipPath, stagingDir, options.signal); } catch (err) { - await safeRemove(stagingDir, options.onStagingCleanupError); + await safeRemove(stagingDir, options.onCleanupError); throw err; } - await safeRemove(stagingDir, options.onStagingCleanupError); + await safeRemove(stagingDir, options.onCleanupError); return counts; }, - options.onTempCleanupError ?? swallowCleanupError, + options.onCleanupError, ); } -function swallowCleanupError(): void { - /* Default: temp-cleanup errors from writeAtomically are silently dropped. */ -} - async function safeRemove( dir: string, onError?: (err: unknown, dir: string) => void, @@ -317,13 +312,3 @@ async function streamFileIntoZip( readStream.destroy(); } } - -/** Like AbortSignal.throwIfAborted() but coerces non-Error reasons to a named AbortError. */ -function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted) { - const reason: unknown = signal.reason; - throw reason instanceof Error - ? reason - : Object.assign(new Error("Aborted"), { name: "AbortError" }); - } -} diff --git a/src/telemetry/export/writers/types.ts b/src/telemetry/export/writers/types.ts new file mode 100644 index 000000000..7c8fd9e1d --- /dev/null +++ b/src/telemetry/export/writers/types.ts @@ -0,0 +1,17 @@ +import type { TelemetryEvent } from "../../event"; + +export type ExportFormat = "json" | "otlp"; + +/** Cancellation and best-effort cleanup hooks shared by every export writer. */ +export interface ExportWriteOptions { + readonly signal?: AbortSignal; + /** A temp file or staging dir could not be removed (caller logs). */ + readonly onCleanupError?: (err: unknown, target: string) => void; +} + +/** Streams `events` to `outputPath`, returning how many were written. */ +export type ExportWriter = ( + outputPath: string, + events: AsyncIterable, + options: ExportWriteOptions, +) => Promise; 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/src/telemetry/sinks/localJsonlSink.ts b/src/telemetry/sinks/localJsonlSink.ts index 3509334ca..66cf639b5 100644 --- a/src/telemetry/sinks/localJsonlSink.ts +++ b/src/telemetry/sinks/localJsonlSink.ts @@ -8,6 +8,7 @@ import { readLocalSinkConfig, type LocalSinkConfig, } from "../../settings/telemetry"; +import { DAY_MS, toUtcDateString } from "../../util/date"; import { cleanupFiles, type FileCleanupCandidate, @@ -19,7 +20,6 @@ import type { Logger } from "../../logging/logger"; import type { TelemetryEvent, TelemetryLevel, TelemetrySink } from "../event"; const SINK_NAME = "local-jsonl"; -const MS_PER_DAY = 24 * 60 * 60 * 1000; export interface LocalJsonlSinkOptions { baseDir: string; @@ -214,7 +214,7 @@ export class LocalJsonlSink implements TelemetrySink, vscode.Disposable { } async #nextFile(payloadSize: number): Promise { - const today = todayUtc(); + const today = toUtcDateString(new Date()); const seeded = this.#current.date === today ? this.#current @@ -252,7 +252,7 @@ export class LocalJsonlSink implements TelemetrySink, vscode.Disposable { filter: (name) => localJsonlFiles.isFileName(name) && !name.includes(sessionMarker), select: selectByAgeAndSize( - this.#config.maxAgeDays * MS_PER_DAY, + this.#config.maxAgeDays * DAY_MS, this.#config.maxTotalBytes, ), }); @@ -298,10 +298,6 @@ async function statBytes(target: string, logger: Logger): Promise { } } -function todayUtc(): string { - return new Date().toISOString().slice(0, 10); -} - function warnIfBufferTooSmall(config: LocalSinkConfig, logger: Logger): void { if (config.bufferLimit < config.flushBatchSize) { logger.warn( diff --git a/src/util/date.ts b/src/util/date.ts new file mode 100644 index 000000000..eec7ab8b4 --- /dev/null +++ b/src/util/date.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +/** Milliseconds in a 24-hour day. */ +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Formats a Date as a UTC YYYY-MM-DD string. toISOString() returns + * `YYYY-MM-DDTHH:mm:ss.sssZ` in UTC, so we take the date part before the "T". + */ +export function toUtcDateString(date: Date): string { + return date.toISOString().split("T")[0]; +} + +const UtcDateSchema = z.iso.date(); + +/** User-facing error string if `value` isn't a UTC date, else `undefined`. */ +export function validateUtcDateInput(value: string): string | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return "Use YYYY-MM-DD."; + } + return UtcDateSchema.safeParse(value).success + ? undefined + : "Enter a valid calendar date."; +} + +/** Parses a YYYY-MM-DD UTC date to epoch ms, throwing on invalid input. */ +export function parseUtcDate(value: string): number { + const error = validateUtcDateInput(value); + if (error !== undefined) { + throw new Error(`Invalid date '${value}': ${error}`); + } + const [y, m, d] = value.split("-").map(Number); + return Date.UTC(y, m - 1, d); +} diff --git a/src/util/fs.ts b/src/util/fs.ts index a152a72cf..17b1fd89e 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -58,13 +58,13 @@ export function tempFilePath(basePath: string, suffix: string): string { * Atomically writes to `outputPath` via a sibling temp file and rename. * The parent directory must already exist. On failure the destination is * left untouched, the temp file is best-effort removed, and the writer - * error is always rethrown. `onCleanupError` receives any error from the - * cleanup attempt; its own throws are swallowed. + * error is always rethrown. `onCleanupError`, if given, receives any error + * from the cleanup attempt; its own throws are swallowed. */ export async function writeAtomically( outputPath: string, write: (tempPath: string) => Promise, - onCleanupError: (err: unknown, tempPath: string) => void, + onCleanupError?: (err: unknown, tempPath: string) => void, ): Promise { const tempPath = tempFilePath(outputPath, "temp"); try { @@ -74,7 +74,7 @@ export async function writeAtomically( } catch (err) { try { await fs.rm(tempPath, { force: true }).catch((rmErr) => { - onCleanupError(rmErr, tempPath); + onCleanupError?.(rmErr, tempPath); }); } catch { // onCleanupError threw; the writer error below takes precedence. 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..69ee2796d --- /dev/null +++ b/test/unit/telemetry/export/command.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { runExportTelemetryCommand } from "@/telemetry/export/command"; +import { collectTelemetryExport } from "@/telemetry/export/pipeline"; +import { promptForExport, type ExportChoice } from "@/telemetry/export/prompts"; + +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; +import { + createMockLogger, + MockProgressReporter, +} from "../../../mocks/testHelpers"; + +// command.ts orchestrates prompts and the pipeline; both are covered in their +// own files and mocked here so these tests focus on command.ts: given a choice +// and a result count, the right notification fires. +vi.mock("@/telemetry/export/prompts", () => ({ promptForExport: vi.fn() })); +vi.mock("@/telemetry/export/pipeline", () => ({ + collectTelemetryExport: vi.fn(), +})); + +const TELEMETRY_DIR = "/tmp/telemetry"; +const OUTPUT_PATH = "/home/user/coder-telemetry.json"; +const REVEAL_ACTION = "Reveal in File Explorer"; +const { context } = createTelemetryEventFactory()(); + +const CHOICE: ExportChoice = { + range: { label: "Last 24 hours", filenamePart: "last-24-hours" }, + format: "json", + outputPath: OUTPUT_PATH, +}; + +function setup( + opts: { + choice?: ExportChoice; + outcome?: { count: number } | { error: unknown }; + revealChoice?: string; + } = {}, +) { + vi.resetAllMocks(); + new MockProgressReporter(); + + vi.mocked(promptForExport).mockResolvedValue(opts.choice ?? CHOICE); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + opts.revealChoice as never, + ); + + const outcome = opts.outcome ?? { count: 2 }; + if ("error" in outcome) { + vi.mocked(collectTelemetryExport).mockRejectedValue(outcome.error); + } else { + vi.mocked(collectTelemetryExport).mockResolvedValue(outcome.count); + } + + return { + run: () => + runExportTelemetryCommand( + TELEMETRY_DIR, + createMockLogger(), + vi.fn(() => Promise.resolve()), + context, + ), + }; +} + +describe("runExportTelemetryCommand", () => { + it("does nothing when the user cancels the prompts", async () => { + const { run } = setup(); + vi.mocked(promptForExport).mockResolvedValue(undefined); + + await run(); + + expect(collectTelemetryExport).not.toHaveBeenCalled(); + expect(vscode.window.withProgress).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + + it("runs the export over the chosen range and destination with a writer", async () => { + const { run } = setup(); + + await run(); + + expect(collectTelemetryExport).toHaveBeenCalledWith( + { + telemetryDir: TELEMETRY_DIR, + range: CHOICE.range, + outputPath: OUTPUT_PATH, + writer: expect.any(Function), + }, + expect.anything(), + ); + }); + + describe("successful export", () => { + it.each([ + [1, "Exported 1 telemetry event to"], + [3, "Exported 3 telemetry events to"], + ])("notifies with a pluralized %i-event count", async (count, message) => { + const { run } = setup({ outcome: { count } }); + + await run(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + `${message} ${OUTPUT_PATH}.`, + REVEAL_ACTION, + ); + }); + + it("reveals the file when the user clicks the action", async () => { + const { run } = setup({ revealChoice: REVEAL_ACTION }); + + await run(); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "revealFileInOS", + vscode.Uri.file(OUTPUT_PATH), + ); + }); + + it("does not reveal when the notification is dismissed", async () => { + const { run } = setup(); + + await run(); + + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it("does not surface an error when revealing fails", async () => { + const { run } = setup({ revealChoice: REVEAL_ACTION }); + vi.mocked(vscode.commands.executeCommand).mockRejectedValue( + new Error("no command"), + ); + + await expect(run()).resolves.toBeUndefined(); + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + }); + + describe("nothing to export", () => { + it("reports that no events were found", async () => { + const { run } = setup({ outcome: { count: 0 } }); + + await run(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + "No telemetry events found for Last 24 hours.", + ); + }); + }); + + describe("failure", () => { + it("shows an error notification without throwing", async () => { + const { run } = setup({ outcome: { error: new Error("disk full") } }); + + await expect(run()).resolves.toBeUndefined(); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Telemetry export failed: disk full", + ); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it("stays silent when the export is cancelled mid-run", async () => { + const aborted = Object.assign(new Error("Aborted"), { + name: "AbortError", + }); + const { run } = setup({ outcome: { error: aborted } }); + + await run(); + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/telemetry/export/pipeline.test.ts b/test/unit/telemetry/export/pipeline.test.ts new file mode 100644 index 000000000..f17842064 --- /dev/null +++ b/test/unit/telemetry/export/pipeline.test.ts @@ -0,0 +1,145 @@ +import * as fsp from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; + +import * as files from "@/telemetry/export/files"; +import { + collectTelemetryExport, + type ExportRequest, + type ExportRuntime, +} from "@/telemetry/export/pipeline"; + +import { asyncIterable } from "../../../mocks/asyncIterable"; +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; + +import type { TelemetryEvent } from "@/telemetry/event"; +import type { TelemetryDateRange } from "@/telemetry/export/range"; +import type { ExportWriter } from "@/telemetry/export/writers/types"; + +vi.mock("@/telemetry/export/files", () => ({ + listTelemetryFilesForRange: vi.fn(), + streamTelemetryEvents: vi.fn(), +})); +vi.mock("node:fs/promises", () => ({ rm: vi.fn(() => Promise.resolve()) })); + +const makeEvent = createTelemetryEventFactory(); +const RANGE: TelemetryDateRange = { + label: "Last 24 hours", + filenamePart: "last-24-hours", +}; +const FILE_PATHS = ["/tmp/telemetry/a.jsonl", "/tmp/telemetry/b.jsonl"]; +const OUTPUT_PATH = "/tmp/out.json"; + +function setup( + opts: { + events?: readonly TelemetryEvent[]; + filePaths?: readonly string[]; + signal?: AbortSignal; + writeCount?: number; + } = {}, +) { + vi.resetAllMocks(); + vi.mocked(fsp.rm).mockResolvedValue(undefined); + vi.mocked(files.listTelemetryFilesForRange).mockResolvedValue([ + ...(opts.filePaths ?? FILE_PATHS), + ]); + vi.mocked(files.streamTelemetryEvents).mockReturnValue( + asyncIterable(opts.events ?? [makeEvent()]), + ); + + const writer = vi.fn(() => + Promise.resolve(opts.writeCount ?? 1), + ); + const flushTelemetry = vi.fn(() => Promise.resolve()); + const runtime: ExportRuntime = { + signal: opts.signal ?? new AbortController().signal, + flushTelemetry, + report: vi.fn(), + onCleanupError: vi.fn(), + }; + const request: ExportRequest = { + telemetryDir: "/tmp/telemetry", + range: RANGE, + outputPath: OUTPUT_PATH, + writer, + }; + return { + runtime, + writer, + flushTelemetry, + run: () => collectTelemetryExport(request, runtime), + }; +} + +describe("collectTelemetryExport", () => { + it("flushes telemetry before listing files", async () => { + const { run, flushTelemetry } = setup(); + + await run(); + + const [flushOrder] = flushTelemetry.mock.invocationCallOrder; + const [listOrder] = vi.mocked(files.listTelemetryFilesForRange).mock + .invocationCallOrder; + expect(flushOrder).toBeLessThan(listOrder); + }); + + it("returns 0 and never writes when no files cover the range", async () => { + const { run, writer } = setup({ filePaths: [] }); + + await expect(run()).resolves.toBe(0); + expect(writer).not.toHaveBeenCalled(); + expect(fsp.rm).not.toHaveBeenCalled(); + }); + + it("returns the writer's event count", async () => { + const { run } = setup({ writeCount: 5 }); + + await expect(run()).resolves.toBe(5); + }); + + it("passes the output path, signal, and cleanup handler to the writer", async () => { + const { run, runtime, writer } = setup({ writeCount: 2 }); + + await run(); + + expect(writer).toHaveBeenCalledWith(OUTPUT_PATH, expect.anything(), { + signal: runtime.signal, + onCleanupError: runtime.onCleanupError, + }); + }); + + it("removes the empty output file when the writer wrote nothing", async () => { + const { run } = setup({ writeCount: 0 }); + + await expect(run()).resolves.toBe(0); + expect(fsp.rm).toHaveBeenCalledWith(OUTPUT_PATH, { force: true }); + }); + + it("keeps the file when at least one event was written", async () => { + const { run } = setup({ writeCount: 1 }); + + await run(); + + expect(fsp.rm).not.toHaveBeenCalled(); + }); + + it("reports a cleanup failure when removing an empty export fails", async () => { + const { run, runtime } = setup({ writeCount: 0 }); + vi.mocked(fsp.rm).mockRejectedValue(new Error("EACCES")); + + await expect(run()).resolves.toBe(0); + expect(runtime.onCleanupError).toHaveBeenCalledWith( + expect.any(Error), + OUTPUT_PATH, + ); + }); + + it("aborts after flushing, before listing, when already cancelled", async () => { + const controller = new AbortController(); + controller.abort(new Error("user cancelled")); + const { run, flushTelemetry } = setup({ signal: controller.signal }); + + await expect(run()).rejects.toThrow("user cancelled"); + expect(flushTelemetry).toHaveBeenCalled(); + expect(files.listTelemetryFilesForRange).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/telemetry/export/prompts.test.ts b/test/unit/telemetry/export/prompts.test.ts new file mode 100644 index 000000000..cf77c29ce --- /dev/null +++ b/test/unit/telemetry/export/prompts.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { promptForExport } from "@/telemetry/export/prompts"; + +const OUTPUT_PATH = "/home/user/coder-telemetry.json"; +const SAVE_URI = vscode.Uri.file(OUTPUT_PATH); +const RANGE_PICK = { id: "last24Hours", label: "Last 24 hours" }; +const CUSTOM_PICK = { id: "custom", label: "Custom range…" }; +const JSON_PICK = { id: "json", label: "JSON array" }; +const OTLP_PICK = { id: "otlp", label: "OTLP/JSON zip" }; + +interface Answers { + range: unknown; + format: unknown; + customStart?: string; + customEnd?: string; + savePath?: vscode.Uri; +} + +const DEFAULT_ANSWERS: Answers = { + range: RANGE_PICK, + format: JSON_PICK, + savePath: SAVE_URI, +}; + +function answer(overrides: Partial = {}): void { + vi.resetAllMocks(); + const a = { ...DEFAULT_ANSWERS, ...overrides }; + + vi.mocked(vscode.window.showQuickPick) + .mockResolvedValueOnce(a.range as never) + .mockResolvedValueOnce(a.format as never); + + const inputBox = vi.mocked(vscode.window.showInputBox); + for (const value of [a.customStart, a.customEnd]) { + if (value !== undefined) { + inputBox.mockResolvedValueOnce(value); + } + } + + vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(a.savePath); +} + +describe("promptForExport", () => { + it("returns the chosen preset range, format, and destination", async () => { + answer(); + + await expect(promptForExport()).resolves.toEqual({ + range: expect.objectContaining({ label: "Last 24 hours" }), + format: "json", + outputPath: OUTPUT_PATH, + }); + }); + + it("builds an inclusive custom range from the entered dates", async () => { + answer({ + range: CUSTOM_PICK, + customStart: "2026-01-01", + customEnd: "2026-01-31", + }); + + const choice = await promptForExport(); + + expect(choice?.range).toMatchObject({ + label: "2026-01-01 to 2026-01-31", + filenamePart: "2026-01-01_to_2026-01-31", + }); + }); + + it.each([ + ["range", { range: undefined }], + ["format", { format: undefined }], + ["destination", { savePath: undefined }], + ["custom start date", { range: CUSTOM_PICK, customStart: undefined }], + [ + "custom end date", + { range: CUSTOM_PICK, customStart: "2026-01-01", customEnd: undefined }, + ], + ])( + "returns undefined when the %s prompt is dismissed", + async (_label, overrides) => { + answer(overrides); + + await expect(promptForExport()).resolves.toBeUndefined(); + }, + ); + + it("sets ignoreFocusOut on every prompt", async () => { + answer({ + range: CUSTOM_PICK, + customStart: "2026-01-01", + customEnd: "2026-01-31", + }); + + await promptForExport(); + + for (const [, options] of vi.mocked(vscode.window.showQuickPick).mock + .calls) { + expect(options).toMatchObject({ ignoreFocusOut: true }); + } + for (const [options] of vi.mocked(vscode.window.showInputBox).mock.calls) { + expect(options).toMatchObject({ ignoreFocusOut: true }); + } + }); + + it("rejects an end date before the start date", async () => { + answer({ + range: CUSTOM_PICK, + customStart: "2026-01-10", + customEnd: "2026-01-31", + }); + + await promptForExport(); + + const endOptions = vi.mocked(vscode.window.showInputBox).mock.calls[1]?.[0]; + expect(endOptions?.validateInput?.("2026-01-05")).toBe( + "End date must be on or after start date.", + ); + }); + + it("offers zip filters and an .otlp.zip default name for OTLP", async () => { + answer({ + format: OTLP_PICK, + savePath: vscode.Uri.file("/home/user/coder-telemetry.otlp.zip"), + }); + + await promptForExport(); + + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { "Zip files": ["zip"] }, + defaultUri: expect.objectContaining({ + path: expect.stringContaining(".otlp.zip"), + }), + }), + ); + }); +}); diff --git a/test/unit/telemetry/export/range.test.ts b/test/unit/telemetry/export/range.test.ts index 46fc3960c..b73fd72d8 100644 --- a/test/unit/telemetry/export/range.test.ts +++ b/test/unit/telemetry/export/range.test.ts @@ -5,18 +5,9 @@ import { createPresetDateRange, isTimestampInRange, fileDateCanContainRangeEvent, - validateUtcDateInput, } from "@/telemetry/export/range"; describe("telemetry export ranges", () => { - it("validates exact UTC calendar dates", () => { - expect(validateUtcDateInput("2026-05-13")).toBeUndefined(); - expect(validateUtcDateInput("2026-5-13")).toBe("Use YYYY-MM-DD."); - expect(validateUtcDateInput("2026-02-30")).toBe( - "Enter a valid calendar date.", - ); - }); - it("builds inclusive custom UTC day ranges", () => { const range = createCustomDateRange("2026-05-12", "2026-05-13"); diff --git a/test/unit/telemetry/export/writers/index.test.ts b/test/unit/telemetry/export/writers/index.test.ts new file mode 100644 index 000000000..750d592f1 --- /dev/null +++ b/test/unit/telemetry/export/writers/index.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createExportWriter } from "@/telemetry/export/writers"; +import { writeJsonArrayExport } from "@/telemetry/export/writers/json"; +import { writeOtlpZipExport } from "@/telemetry/export/writers/otlp/writer"; + +import { asyncIterable } from "../../../../mocks/asyncIterable"; +import { createTelemetryEventFactory } from "../../../../mocks/telemetry"; + +vi.mock("@/telemetry/export/writers/json", () => ({ + writeJsonArrayExport: vi.fn(), +})); +vi.mock("@/telemetry/export/writers/otlp/writer", () => ({ + writeOtlpZipExport: vi.fn(), +})); + +const { context } = createTelemetryEventFactory()(); +const OUTPUT = "/tmp/out"; +const EVENTS = asyncIterable([]); +const OPTIONS = { + signal: new AbortController().signal, + onCleanupError: vi.fn(), +}; + +describe("createExportWriter", () => { + it("uses the JSON writer for the json format", () => { + expect(createExportWriter("json", context)).toBe(writeJsonArrayExport); + }); + + it("binds the context and sums signal counts for the otlp format", async () => { + vi.mocked(writeOtlpZipExport).mockResolvedValue({ + logs: 5, + traces: 3, + metrics: 1, + }); + + const writer = createExportWriter("otlp", context); + + await expect(writer(OUTPUT, EVENTS, OPTIONS)).resolves.toBe(9); + expect(writeOtlpZipExport).toHaveBeenCalledWith( + OUTPUT, + EVENTS, + context, + OPTIONS, + ); + }); +}); diff --git a/test/unit/telemetry/export/writers/json.test.ts b/test/unit/telemetry/export/writers/json.test.ts index aa9ac8ebc..303f14e3a 100644 --- a/test/unit/telemetry/export/writers/json.test.ts +++ b/test/unit/telemetry/export/writers/json.test.ts @@ -23,7 +23,6 @@ beforeEach(() => { afterEach(() => vol.reset()); const readOut = () => JSON.parse(vol.readFileSync(OUT, "utf8") as string); -const noopCleanup = () => {}; describe("writeJsonArrayExport", () => { it("writes events in wire format and returns the count", async () => { @@ -32,22 +31,14 @@ describe("writeJsonArrayExport", () => { makeEvent({ eventName: "second", error: { message: "boom" } }), ]; - const count = await writeJsonArrayExport( - OUT, - asyncIterable(events), - noopCleanup, - ); + const count = await writeJsonArrayExport(OUT, asyncIterable(events)); expect(count).toBe(2); expect(readOut()).toEqual(events.map(serializeTelemetryEvent)); }); it("writes a valid empty array for empty input", async () => { - const count = await writeJsonArrayExport( - OUT, - asyncIterable([]), - noopCleanup, - ); + const count = await writeJsonArrayExport(OUT, asyncIterable([])); expect(count).toBe(0); expect(readOut()).toEqual([]); @@ -61,9 +52,7 @@ describe("writeJsonArrayExport", () => { throw new Error("boom"); })(); - await expect( - writeJsonArrayExport(OUT, failing, noopCleanup), - ).rejects.toThrow(/boom/); + await expect(writeJsonArrayExport(OUT, failing)).rejects.toThrow(/boom/); expect(vol.readFileSync(OUT, "utf8")).toBe("previous"); expect(vol.readdirSync("/exports")).toEqual(["telemetry.json"]); diff --git a/test/unit/telemetry/export/writers/otlp/writer.test.ts b/test/unit/telemetry/export/writers/otlp/writer.test.ts index 0e913d799..4160d5935 100644 --- a/test/unit/telemetry/export/writers/otlp/writer.test.ts +++ b/test/unit/telemetry/export/writers/otlp/writer.test.ts @@ -207,7 +207,7 @@ describe("writeOtlpZipExport", () => { expect(metrics.records).toEqual([]); }); - it("reports staging cleanup failures via onStagingCleanupError instead of masking success", async () => { + it("reports staging cleanup failures via onCleanupError instead of masking success", async () => { const fsPromises = await import("node:fs/promises"); const cleanupErrors: Array<{ err: unknown; dir: string }> = []; const spy = vi @@ -222,7 +222,7 @@ describe("writeOtlpZipExport", () => { asyncIterable([makeEvent()]), context, { - onStagingCleanupError: (err, dir) => cleanupErrors.push({ err, dir }), + onCleanupError: (err, dir) => cleanupErrors.push({ err, dir }), }, ); @@ -235,7 +235,7 @@ describe("writeOtlpZipExport", () => { } }); - it("does not surface onStagingCleanupError throws to callers", async () => { + it("does not surface onCleanupError throws to callers", async () => { const fsPromises = await import("node:fs/promises"); const spy = vi .spyOn(fsPromises, "rm") @@ -247,7 +247,7 @@ describe("writeOtlpZipExport", () => { asyncIterable([makeEvent()]), context, { - onStagingCleanupError: () => { + onCleanupError: () => { throw new Error("logger blew up"); }, }, 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(); + }); + }); }); diff --git a/test/unit/util/date.test.ts b/test/unit/util/date.test.ts new file mode 100644 index 000000000..dc6fa859d --- /dev/null +++ b/test/unit/util/date.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + parseUtcDate, + toUtcDateString, + validateUtcDateInput, +} from "@/util/date"; + +describe("toUtcDateString", () => { + it("formats a Date as a UTC YYYY-MM-DD string", () => { + expect(toUtcDateString(new Date("2026-01-31T12:00:00Z"))).toBe( + "2026-01-31", + ); + }); + + it("uses the UTC day regardless of the time within the day", () => { + expect(toUtcDateString(new Date("2026-06-15T23:30:00Z"))).toBe( + "2026-06-15", + ); + expect(toUtcDateString(new Date("2026-06-16T00:30:00Z"))).toBe( + "2026-06-16", + ); + }); + + it("zero-pads years below 1000", () => { + expect(toUtcDateString(new Date("0099-06-15T00:00:00Z"))).toBe( + "0099-06-15", + ); + }); + + it("preserves expanded years beyond 9999", () => { + expect(toUtcDateString(new Date(Date.UTC(275760, 8, 13)))).toBe( + "+275760-09-13", + ); + }); + + it("preserves negative (BCE) years", () => { + expect(toUtcDateString(new Date(Date.UTC(-1, 0, 1)))).toBe("-000001-01-01"); + }); +}); + +describe("validateUtcDateInput", () => { + it("accepts an exact UTC calendar date", () => { + expect(validateUtcDateInput("2026-05-13")).toBeUndefined(); + }); + + it("rejects non YYYY-MM-DD formatting", () => { + expect(validateUtcDateInput("2026-5-13")).toBe("Use YYYY-MM-DD."); + }); + + it("rejects an impossible calendar date", () => { + expect(validateUtcDateInput("2026-02-30")).toBe( + "Enter a valid calendar date.", + ); + }); +}); + +describe("parseUtcDate", () => { + it("parses a YYYY-MM-DD date to UTC epoch ms", () => { + expect(parseUtcDate("2026-05-13")).toBe(Date.UTC(2026, 4, 13)); + }); + + it("throws on invalid input", () => { + expect(() => parseUtcDate("2026-02-30")).toThrow("Invalid date"); + }); +});