diff --git a/src/telemetry/event.ts b/src/telemetry/event.ts index 1749a068a2..7de8ad8c08 100644 --- a/src/telemetry/event.ts +++ b/src/telemetry/event.ts @@ -3,6 +3,14 @@ import * as vscode from "vscode"; import { toError } from "../error/errorUtils"; +export type { + SessionContext, + TelemetryContext, + TelemetryEvent, +} from "./wireFormat"; + +import type { SessionContext, TelemetryEvent } from "./wireFormat"; + /** Telemetry level, mirrors `coder.telemetry.level`. Ordered: off < local. */ export type TelemetryLevel = "off" | "local"; @@ -19,47 +27,6 @@ export type CallerMeasurements = Record & { durationMs?: never; }; -/** Session-stable resource attributes. Field names are inspired by OTel - * resource attributes; they are camelCase TypeScript and not a 1:1 mapping. */ -export interface SessionContext { - readonly extensionVersion: string; - readonly machineId: string; - readonly sessionId: string; - readonly osType: string; - readonly osVersion: string; - readonly hostArch: string; - readonly platformName: string; - readonly platformVersion: string; -} - -/** Per-event context: session attributes plus the current deployment URL. */ -export interface TelemetryContext extends SessionContext { - readonly deploymentUrl: string; -} - -export interface TelemetryEvent { - readonly eventId: string; - readonly eventName: string; - readonly timestamp: string; - readonly eventSequence: number; - - readonly context: TelemetryContext; - - readonly properties: Readonly>; - readonly measurements: Readonly>; - - /** Shared by all events in a trace. Maps to OTel `trace_id`. */ - readonly traceId?: string; - /** Set on phase children only. Equals the parent event's `eventId`. Maps to OTel `parent_span_id`. */ - readonly parentEventId?: string; - - readonly error?: Readonly<{ - message: string; - type?: string; - code?: string; - }>; -} - /** * Sink for telemetry events. `write` is sync and must buffer in memory; I/O * happens in `flush`/`dispose`. The service filters by `minLevel`; sinks can diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts new file mode 100644 index 0000000000..3f7d3e35b8 --- /dev/null +++ b/src/telemetry/export/files.ts @@ -0,0 +1,128 @@ +import { createReadStream } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as readline from "node:readline"; + +import { toError } from "../../error/errorUtils"; +import { + parseTelemetryEventLine, + TelemetryFileParseError, +} from "../wireFormat"; + +import { + fileDateCanContainRangeEvent, + isTimestampInRange, + type TelemetryDateRange, +} from "./range"; + +import type { TelemetryEvent } from "../event"; + +interface TelemetryLogFile { + readonly path: string; + readonly date: string; + readonly session: string; + readonly part: number; +} + +/** + * Filename shape written by the sink: + * `telemetry-YYYY-MM-DD-{session}[.{part}].jsonl`. We need the date to filter + * and (session, part) to order files within a day. + */ +const TELEMETRY_FILE_PATTERN = + /^telemetry-(\d{4}-\d{2}-\d{2})-([^.]+)(?:\.(\d+))?\.jsonl$/; + +/** Log files that could contain events in `range`, in chronological order. */ +export async function listTelemetryFilesForRange( + telemetryDir: string, + range: TelemetryDateRange, +): Promise { + let names: string[]; + try { + names = await fs.readdir(telemetryDir); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw err; + } + + return names + .map((name) => parseLogFilename(telemetryDir, name)) + .filter( + (file): file is TelemetryLogFile => + file !== undefined && fileDateCanContainRangeEvent(file.date, range), + ) + .sort(compareLogFiles) + .map(({ path: filePath }) => filePath); +} + +/** + * Yields events from `filePaths` in order, keeping only those whose timestamp + * falls inside `range`. Reads line-by-line so memory stays flat on big files. + */ +export async function* streamTelemetryEvents( + filePaths: readonly string[], + range: TelemetryDateRange, +): AsyncIterable { + for (const filePath of filePaths) { + const name = path.basename(filePath); + const stream = createReadStream(filePath, { encoding: "utf8" }); + const lines = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + let lineNumber = 0; + try { + for await (const line of lines) { + lineNumber += 1; + if (line.trim() === "") { + continue; + } + const event = parseTelemetryEventLine(line, name, lineNumber); + if (isTimestampInRange(event.timestamp, range)) { + yield event; + } + } + } catch (err) { + if (err instanceof TelemetryFileParseError) { + throw err; + } + const at = lineNumber > 0 ? `:${lineNumber}` : ""; + throw new Error( + `Failed to read telemetry file ${name}${at}: ${toError(err).message}`, + { cause: err }, + ); + } finally { + try { + lines.close(); + } finally { + stream.destroy(); + } + } + } +} + +function parseLogFilename( + dir: string, + name: string, +): TelemetryLogFile | undefined { + const match = TELEMETRY_FILE_PATTERN.exec(name); + if (!match) { + return undefined; + } + return { + path: path.join(dir, name), + date: match[1], + session: match[2], + part: match[3] === undefined ? 0 : Number(match[3]), + }; +} + +function compareLogFiles(a: TelemetryLogFile, b: TelemetryLogFile): number { + return ( + a.date.localeCompare(b.date) || + a.session.localeCompare(b.session) || + a.part - b.part + ); +} diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts new file mode 100644 index 0000000000..52e46ac225 --- /dev/null +++ b/src/telemetry/export/range.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const UtcDateSchema = z.iso.date(); + +/** + * Half-open UTC window `[startMs, endMs)` used to filter telemetry. Either + * bound may be `undefined` (e.g. "all time"). `label` is for the UI and + * `filenamePart` is for export filenames. + */ +export interface TelemetryDateRange { + readonly label: string; + readonly filenamePart: string; + readonly startMs?: number; + readonly endMs?: number; +} + +export interface TelemetryRangePreset { + readonly id: TelemetryRangePresetId; + readonly label: string; + readonly detail: string; +} + +export type TelemetryRangePresetId = keyof typeof PRESETS; + +const PRESETS = { + last24Hours: { + label: "Last 24 hours", + detail: "Export telemetry from the last day.", + filenamePart: "last-24-hours", + durationMs: DAY_MS, + }, + last7Days: { + label: "Last 7 days", + detail: "Export telemetry from the last week.", + filenamePart: "last-7-days", + durationMs: 7 * DAY_MS, + }, + last30Days: { + label: "Last 30 days", + detail: "Export telemetry from the last month.", + filenamePart: "last-30-days", + durationMs: 30 * DAY_MS, + }, + allTime: { + label: "All time", + detail: "Export all stored telemetry.", + filenamePart: "all-time", + durationMs: undefined, + }, +} as const; + +/** Presets the export UI shows, in display order. */ +export const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = + Object.entries(PRESETS).map(([id, p]) => ({ + id: id as TelemetryRangePresetId, + label: p.label, + detail: p.detail, + })); + +/** Range from a preset id, anchored at `now`. */ +export function createPresetDateRange( + id: TelemetryRangePresetId, + now: Date = new Date(), +): TelemetryDateRange { + const { label, filenamePart, durationMs } = PRESETS[id]; + if (durationMs === undefined) { + return { label, filenamePart }; + } + const endMs = now.getTime(); + return { label, filenamePart, startMs: endMs - durationMs, endMs }; +} + +/** + * UTC range that includes the full 24h of `endDate`; `endMs` lands at + * exclusive midnight of the day after. + */ +export function createCustomDateRange( + startDate: string, + endDate: string, +): TelemetryDateRange { + const startDateMs = parseUtcDate(startDate); + const endDateMs = parseUtcDate(endDate); + if (endDateMs < startDateMs) { + throw new Error("End date must be on or after start date."); + } + return { + label: `${startDate} to ${endDate}`, + filenamePart: `${startDate}_to_${endDate}`, + startMs: startDateMs, + endMs: endDateMs + DAY_MS, + }; +} + +/** 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."; +} + +/** True if the ISO `timestamp` falls inside the range. */ +export function isTimestampInRange( + timestamp: string, + range: TelemetryDateRange, +): boolean { + const ms = Date.parse(timestamp); + if (!Number.isFinite(ms)) { + throw new Error(`Invalid telemetry timestamp '${timestamp}'.`); + } + return ( + (range.startMs === undefined || ms >= range.startMs) && + (range.endMs === undefined || ms < range.endMs) + ); +} + +/** + * Coarse calendar-day filter: could a file dated `date` (YYYY-MM-DD) hold any + * event in `range`? Lets us skip files without reading them. + */ +export function fileDateCanContainRangeEvent( + date: string, + range: TelemetryDateRange, +): boolean { + const startDate = + range.startMs === undefined ? undefined : utcDateString(range.startMs); + const endDate = + range.endMs === undefined ? undefined : utcDateString(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/sinks/localJsonlSink.ts b/src/telemetry/sinks/localJsonlSink.ts index 5ac7fbb3aa..b0d7e3ef95 100644 --- a/src/telemetry/sinks/localJsonlSink.ts +++ b/src/telemetry/sinks/localJsonlSink.ts @@ -12,6 +12,7 @@ import { cleanupFiles, type FileCleanupCandidate, } from "../../util/fileCleanup"; +import { serializeTelemetryEventLine } from "../wireFormat"; import type { Logger } from "../../logging/logger"; import type { TelemetryEvent, TelemetryLevel, TelemetrySink } from "../event"; @@ -88,7 +89,7 @@ export class LocalJsonlSink implements TelemetrySink, vscode.Disposable { } let line: string; try { - line = serializeEvent(event); + line = serializeTelemetryEventLine(event); } catch (err) { this.#logger.warn(`Telemetry sink '${this.name}' serialize failed`, err); return; @@ -313,32 +314,3 @@ function toSessionSlug(sessionId: string): string { const cleaned = sessionId.replace(/[^a-zA-Z0-9]/g, ""); return cleaned.slice(0, 8) || "anon0000"; } - -function serializeEvent(event: TelemetryEvent): string { - return ( - JSON.stringify({ - event_id: event.eventId, - event_name: event.eventName, - timestamp: event.timestamp, - event_sequence: event.eventSequence, - context: { - extension_version: event.context.extensionVersion, - machine_id: event.context.machineId, - session_id: event.context.sessionId, - os_type: event.context.osType, - os_version: event.context.osVersion, - host_arch: event.context.hostArch, - platform_name: event.context.platformName, - platform_version: event.context.platformVersion, - deployment_url: event.context.deploymentUrl, - }, - properties: event.properties, - measurements: event.measurements, - ...(event.traceId !== undefined && { trace_id: event.traceId }), - ...(event.parentEventId !== undefined && { - parent_event_id: event.parentEventId, - }), - ...(event.error !== undefined && { error: event.error }), - }) + "\n" - ); -} diff --git a/src/telemetry/wireFormat.ts b/src/telemetry/wireFormat.ts new file mode 100644 index 0000000000..69b71dbfdd --- /dev/null +++ b/src/telemetry/wireFormat.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; + +/** Session-stable resource attributes captured once per VS Code session. */ +const SessionContextSchema = z.object({ + extensionVersion: z.string(), + machineId: z.string(), + sessionId: z.string(), + osType: z.string(), + osVersion: z.string(), + hostArch: z.string(), + platformName: z.string(), + platformVersion: z.string(), +}); + +/** Session attributes plus the deployment URL active at emit time. */ +const TelemetryContextSchema = SessionContextSchema.extend({ + deploymentUrl: z.string(), +}); + +/** + * Canonical telemetry event. Derived TS types (`TelemetryEvent`, + * `TelemetryContext`, `SessionContext`) come straight from these schemas, + * so the wire format and the in-memory shape can't drift. + */ +const TelemetryEventSchema = z.object({ + eventId: z.string(), + eventName: z.string(), + timestamp: z.iso.datetime({ offset: true }), + eventSequence: z.number(), + context: TelemetryContextSchema, + properties: z.record(z.string(), z.string()), + measurements: z.record(z.string(), z.number()), + /** Shared by all events in a trace. Maps to OTel `trace_id`. */ + traceId: z.string().optional(), + /** Parent event in the same trace. Maps to OTel `parent_span_id`. */ + parentEventId: z.string().optional(), + error: z + .object({ + message: z.string(), + type: z.string().optional(), + code: z.string().optional(), + }) + .optional(), +}); + +/** Deep `readonly` since zod's inferred types are mutable by default. */ +type DeepReadonly = T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +export type SessionContext = DeepReadonly>; +export type TelemetryContext = DeepReadonly< + z.infer +>; +export type TelemetryEvent = DeepReadonly>; + +/** Lets stream readers tell a parse failure apart from an IO failure. */ +export class TelemetryFileParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "TelemetryFileParseError"; + } +} + +/** Serializes one event to its newline-terminated JSONL row. */ +export function serializeTelemetryEventLine(event: TelemetryEvent): string { + return JSON.stringify(serializeTelemetryEvent(event)) + "\n"; +} + +/** Snake-case row written to disk by the sink and read back by the exporter. */ +export function serializeTelemetryEvent( + event: TelemetryEvent, +): Record { + return { + event_id: event.eventId, + event_name: event.eventName, + timestamp: event.timestamp, + event_sequence: event.eventSequence, + context: { + extension_version: event.context.extensionVersion, + machine_id: event.context.machineId, + session_id: event.context.sessionId, + os_type: event.context.osType, + os_version: event.context.osVersion, + host_arch: event.context.hostArch, + platform_name: event.context.platformName, + platform_version: event.context.platformVersion, + deployment_url: event.context.deploymentUrl, + }, + properties: event.properties, + measurements: event.measurements, + ...(event.traceId !== undefined && { trace_id: event.traceId }), + ...(event.parentEventId !== undefined && { + parent_event_id: event.parentEventId, + }), + ...(event.error !== undefined && { error: event.error }), + }; +} + +/** Parses one JSONL row. Throws `TelemetryFileParseError` on bad input. */ +export function parseTelemetryEventLine( + line: string, + source: string, + lineNumber: number, +): TelemetryEvent { + try { + return TelemetryEventSchema.parse(wireToCamel(JSON.parse(line))); + } catch (err) { + throw new TelemetryFileParseError( + `Failed to parse telemetry file ${source}:${lineNumber}: ${describeParseError(err)}`, + { cause: err }, + ); + } +} + +const SNAKE_TO_CAMEL = /_([a-z])/g; + +/** + * Snake-case to camelCase for structural keys only (top-level + `context`). + * `properties` and `measurements` hold caller-supplied keys we must keep as-is. + */ +function wireToCamel(value: unknown): unknown { + const obj = value as Record; + const next = renameKeys(obj); + const context = next.context; + if (typeof context === "object" && context !== null) { + next.context = renameKeys(context as Record); + } + return next; +} + +function renameKeys(obj: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + out[key.replace(SNAKE_TO_CAMEL, (_, ch: string) => ch.toUpperCase())] = + value; + } + return out; +} + +function describeParseError(err: unknown): string { + if (err instanceof z.ZodError) { + return z.prettifyError(err); + } + if (err instanceof Error) { + return err.message; + } + return String(err); +} diff --git a/test/mocks/telemetry.ts b/test/mocks/telemetry.ts index 3d954d32c5..f0c46b91f6 100644 --- a/test/mocks/telemetry.ts +++ b/test/mocks/telemetry.ts @@ -70,3 +70,36 @@ export function createTelemetryHarness(): { const sink = new TestSink(); return { sink, service: createTestTelemetryService(sink) }; } + +/** + * Factory for `TelemetryEvent` fixtures. Each call gets a fresh `eventId` and + * monotonic `eventSequence`; overrides win. + */ +export function createTelemetryEventFactory(): ( + overrides?: Partial, +) => TelemetryEvent { + let sequence = 0; + return (overrides = {}) => { + const seq = sequence++; + return { + eventId: `id-${seq}`, + eventName: "test.event", + timestamp: "2026-05-04T12:00:00.000Z", + eventSequence: seq, + context: { + extensionVersion: "1.14.5", + machineId: "machine-id", + sessionId: "session-id", + osType: "linux", + osVersion: "6.0.0", + hostArch: "x64", + platformName: "Visual Studio Code", + platformVersion: "1.106.0", + deploymentUrl: "https://coder.example.com", + }, + properties: {}, + measurements: {}, + ...overrides, + }; + }; +} diff --git a/test/unit/telemetry/export/files.test.ts b/test/unit/telemetry/export/files.test.ts new file mode 100644 index 0000000000..2538edd30d --- /dev/null +++ b/test/unit/telemetry/export/files.test.ts @@ -0,0 +1,121 @@ +import { vol } from "memfs"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listTelemetryFilesForRange, + streamTelemetryEvents, +} from "@/telemetry/export/files"; +import { createCustomDateRange } from "@/telemetry/export/range"; +import { serializeTelemetryEventLine } from "@/telemetry/wireFormat"; + +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; + +import type * as fs from "node:fs"; + +import type { TelemetryEvent } from "@/telemetry/event"; + +vi.mock("node:fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs.promises; +}); + +vi.mock("node:fs", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs; +}); + +const DIR = "/telemetry"; + +let makeEvent: ReturnType; + +beforeEach(() => { + vol.reset(); + vol.mkdirSync(DIR, { recursive: true }); + makeEvent = createTelemetryEventFactory(); +}); + +afterEach(() => { + vol.reset(); +}); + +describe("listTelemetryFilesForRange", () => { + it("keeps only telemetry files dated inside the range", async () => { + writeFiles({ + "telemetry-2026-05-11-aaaaaaaa.jsonl": "", + "telemetry-2026-05-12-bbbbbbbb.jsonl": "", + "telemetry-2026-05-12-bbbbbbbb.1.jsonl": "", + "telemetry-2026-05-14-cccccccc.jsonl": "", + "notes-2026-05-12.jsonl": "", + }); + + const files = await listTelemetryFilesForRange( + DIR, + createCustomDateRange("2026-05-12", "2026-05-13"), + ); + + expect(files.map((p) => path.basename(p))).toEqual([ + "telemetry-2026-05-12-bbbbbbbb.jsonl", + "telemetry-2026-05-12-bbbbbbbb.1.jsonl", + ]); + }); + + it("returns an empty list when the telemetry directory is missing", async () => { + await expect( + listTelemetryFilesForRange( + `${DIR}/missing`, + createCustomDateRange("2026-05-12", "2026-05-13"), + ), + ).resolves.toEqual([]); + }); +}); + +describe("streamTelemetryEvents", () => { + it("yields only events whose timestamp falls inside the range", async () => { + writeFiles({ + "telemetry-2026-05-12-aaaaaaaa.jsonl": + serializeTelemetryEventLine( + makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" }), + ) + + serializeTelemetryEventLine( + makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" }), + ), + }); + + const events: TelemetryEvent[] = []; + for await (const event of streamTelemetryEvents( + [`${DIR}/telemetry-2026-05-12-aaaaaaaa.jsonl`], + createCustomDateRange("2026-05-12", "2026-05-12"), + )) { + events.push(event); + } + + expect(events).toHaveLength(1); + expect(events[0].timestamp).toBe("2026-05-12T00:00:00.000Z"); + }); + + it("surfaces parse errors with file:line context", async () => { + writeFiles({ + "telemetry-2026-05-12-aaaaaaaa.jsonl": "{not-json}\n", + }); + + await expect(drain("telemetry-2026-05-12-aaaaaaaa.jsonl")).rejects.toThrow( + /Failed to parse telemetry file telemetry-2026-05-12-aaaaaaaa\.jsonl:1/, + ); + }); +}); + +function writeFiles(files: Record): void { + for (const [name, content] of Object.entries(files)) { + vol.writeFileSync(`${DIR}/${name}`, content); + } +} + +async function drain(name: string): Promise { + for await (const _ of streamTelemetryEvents( + [`${DIR}/${name}`], + createCustomDateRange("2026-05-12", "2026-05-12"), + )) { + // Pull the iterator to surface parse errors. + } +} diff --git a/test/unit/telemetry/export/range.test.ts b/test/unit/telemetry/export/range.test.ts new file mode 100644 index 0000000000..46fc3960cf --- /dev/null +++ b/test/unit/telemetry/export/range.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + createCustomDateRange, + 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"); + + expect(range).toMatchObject({ + label: "2026-05-12 to 2026-05-13", + filenamePart: "2026-05-12_to_2026-05-13", + }); + expect(isTimestampInRange("2026-05-12T00:00:00.000Z", range)).toBe(true); + expect(isTimestampInRange("2026-05-13T23:59:59.999Z", range)).toBe(true); + expect(isTimestampInRange("2026-05-14T00:00:00.000Z", range)).toBe(false); + }); + + it("rejects custom ranges whose end is before the start", () => { + expect(() => createCustomDateRange("2026-05-13", "2026-05-12")).toThrow( + /End date/, + ); + }); + + it("checks filename UTC dates against preset ranges", () => { + const range = createPresetDateRange( + "last24Hours", + new Date("2026-05-13T12:00:00.000Z"), + ); + + expect(fileDateCanContainRangeEvent("2026-05-11", range)).toBe(false); + expect(fileDateCanContainRangeEvent("2026-05-12", range)).toBe(true); + expect(fileDateCanContainRangeEvent("2026-05-13", range)).toBe(true); + expect(fileDateCanContainRangeEvent("2026-05-14", range)).toBe(false); + }); + + it("includes every filename date for all time", () => { + const range = createPresetDateRange("allTime"); + + expect(fileDateCanContainRangeEvent("2020-01-01", range)).toBe(true); + expect(fileDateCanContainRangeEvent("2026-05-13", range)).toBe(true); + }); +}); diff --git a/test/unit/telemetry/sinks/localJsonlSink.test.ts b/test/unit/telemetry/sinks/localJsonlSink.test.ts index 745bac6c4d..c2191f4a2c 100644 --- a/test/unit/telemetry/sinks/localJsonlSink.test.ts +++ b/test/unit/telemetry/sinks/localJsonlSink.test.ts @@ -8,6 +8,7 @@ import { } from "@/settings/telemetry"; import { LocalJsonlSink } from "@/telemetry/sinks/localJsonlSink"; +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; import { createMockLogger, MockConfigurationProvider, @@ -15,8 +16,6 @@ import { import type * as fs from "node:fs"; -import type { TelemetryEvent } from "@/telemetry/event"; - vi.mock("node:fs/promises", async () => { const memfs: { fs: typeof fs } = await vi.importActual("memfs"); return memfs.fs.promises; @@ -78,31 +77,7 @@ describe("LocalJsonlSink", () => { const sink = LocalJsonlSink.start({ baseDir: BASE_DIR, sessionId }, logger); active.push(sink); - let seq = 0; - const makeEvent = ( - overrides: Partial = {}, - ): TelemetryEvent => ({ - eventId: `id-${seq}`, - eventName: "test.event", - timestamp: "2026-05-04T12:00:00.000Z", - eventSequence: seq++, - context: { - extensionVersion: "1.14.5", - machineId: "machine-id", - sessionId: "session-id", - osType: "linux", - osVersion: "6.0.0", - hostArch: "x64", - platformName: "Visual Studio Code", - platformVersion: "1.106.0", - deploymentUrl: "https://coder.example.com", - }, - properties: {}, - measurements: {}, - ...overrides, - }); - - return { sink, logger, makeEvent }; + return { sink, logger, makeEvent: createTelemetryEventFactory() }; } it("flushes the buffer when the interval fires", async () => { @@ -207,8 +182,7 @@ describe("LocalJsonlSink", () => { it("rotates to a numbered segment once maxFileBytes is exceeded", async () => { // Padded events are ~2000 bytes; 4500 holds 2 events but not 3. const { sink, makeEvent } = setup({ maxFileBytes: 4500 }); - const padded = (): TelemetryEvent => - makeEvent({ properties: { pad: "x".repeat(1500) } }); + const padded = () => makeEvent({ properties: { pad: "x".repeat(1500) } }); for (let i = 0; i < 3; i++) { sink.write(padded()); diff --git a/test/unit/telemetry/wireFormat.test.ts b/test/unit/telemetry/wireFormat.test.ts new file mode 100644 index 0000000000..98cdc1300c --- /dev/null +++ b/test/unit/telemetry/wireFormat.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + parseTelemetryEventLine, + serializeTelemetryEvent, + TelemetryFileParseError, +} from "@/telemetry/wireFormat"; + +import { createTelemetryEventFactory } from "../../mocks/telemetry"; + +import type { TelemetryEvent } from "@/telemetry/event"; + +const makeEvent = createTelemetryEventFactory(); + +describe("serializeTelemetryEvent", () => { + it("writes snake_case keys at the top level and inside context", () => { + const wire = serializeTelemetryEvent( + makeEvent({ traceId: "trace-1", parentEventId: "parent-1" }), + ); + + expect(wire).toMatchObject({ + event_id: expect.any(String), + event_name: "test.event", + event_sequence: expect.any(Number), + trace_id: "trace-1", + parent_event_id: "parent-1", + context: { + extension_version: "1.14.5", + machine_id: "machine-id", + deployment_url: "https://coder.example.com", + }, + }); + }); + + it("omits trace_id, parent_event_id, and error when unset", () => { + const wire = serializeTelemetryEvent(makeEvent()); + + expect(wire).not.toHaveProperty("trace_id"); + expect(wire).not.toHaveProperty("parent_event_id"); + expect(wire).not.toHaveProperty("error"); + }); + + it("passes properties and measurements through unchanged", () => { + const wire = serializeTelemetryEvent( + makeEvent({ + properties: { user_id: "alice", camelCase: "kept" }, + measurements: { duration_ms: 42 }, + }), + ); + + expect(wire.properties).toEqual({ user_id: "alice", camelCase: "kept" }); + expect(wire.measurements).toEqual({ duration_ms: 42 }); + }); +}); + +describe("parseTelemetryEventLine", () => { + it("round-trips serialize -> parse to the original event", () => { + const event = makeEvent({ + traceId: "trace-1", + parentEventId: "parent-1", + error: { message: "boom", type: "Error", code: "E_BOOM" }, + properties: { a: "1" }, + measurements: { b: 2 }, + }); + + const parsed = parseTelemetryEventLine( + JSON.stringify(serializeTelemetryEvent(event)), + "", + 1, + ); + + expect(parsed).toEqual(event); + }); + + it("throws TelemetryFileParseError tagged with source:lineNumber", () => { + expect.assertions(3); + try { + parseTelemetryEventLine("{not-json}", "events.jsonl", 7); + } catch (err) { + expect(err).toBeInstanceOf(TelemetryFileParseError); + expect((err as Error).message).toMatch(/events\.jsonl:7/); + expect((err as Error).cause).toBeDefined(); + } + }); + + it("rejects events with timestamps that are not valid ISO datetimes", () => { + const wire = serializeTelemetryEvent( + makeEvent({ timestamp: "2026-02-30T00:00:00.000Z" }), + ); + + expect(() => + parseTelemetryEventLine(JSON.stringify(wire), "", 1), + ).toThrow(TelemetryFileParseError); + }); + + it("rejects rows missing required structural fields", () => { + const wire = serializeTelemetryEvent(makeEvent()); + delete wire.context; + + expect(() => + parseTelemetryEventLine(JSON.stringify(wire), "", 1), + ).toThrow(TelemetryFileParseError); + }); + + it("preserves arbitrary keys in properties and measurements", () => { + const event: TelemetryEvent = makeEvent({ + properties: { user_id: "alice", camelCase: "kept" }, + measurements: { duration_ms: 42 }, + }); + + const parsed = parseTelemetryEventLine( + JSON.stringify(serializeTelemetryEvent(event)), + "", + 1, + ); + + expect(parsed.properties).toEqual({ + user_id: "alice", + camelCase: "kept", + }); + expect(parsed.measurements).toEqual({ duration_ms: 42 }); + }); +});