From 8453b8e9d19b6586188ba2d23c7c73929c63dac1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 17 May 2026 17:07:58 +0000 Subject: [PATCH 1/4] feat(telemetry): add export event reader --- src/telemetry/export/files.ts | 227 +++++++++++++++++++++++ src/telemetry/export/range.ts | 155 ++++++++++++++++ src/telemetry/export/types.ts | 9 + test/unit/telemetry/export/files.test.ts | 193 +++++++++++++++++++ test/unit/telemetry/export/range.test.ts | 57 ++++++ 5 files changed, 641 insertions(+) create mode 100644 src/telemetry/export/files.ts create mode 100644 src/telemetry/export/range.ts create mode 100644 src/telemetry/export/types.ts create mode 100644 test/unit/telemetry/export/files.test.ts create mode 100644 test/unit/telemetry/export/range.test.ts diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts new file mode 100644 index 000000000..59dde10a1 --- /dev/null +++ b/src/telemetry/export/files.ts @@ -0,0 +1,227 @@ +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 { z } from "zod"; + +import { + fileDateCanContainRangeEvent, + isTimestampInRange, + type TelemetryDateRange, +} from "./range"; + +import type { ExportTelemetryEvent } from "./types"; + +const TELEMETRY_FILE_PATTERN = + /^telemetry-(\d{4}-\d{2}-\d{2})-([a-zA-Z0-9]+)(?:\.(\d+))?\.jsonl$/; + +const StoredTelemetryEventSchema = z.object({ + event_id: z.string(), + event_name: z.string(), + timestamp: z.iso.datetime({ offset: true }), + event_sequence: z.number().finite(), + context: z.object({ + extension_version: z.string(), + machine_id: z.string(), + session_id: z.string(), + os_type: z.string(), + os_version: z.string(), + host_arch: z.string(), + platform_name: z.string(), + platform_version: z.string(), + deployment_url: z.string(), + }), + properties: z.record(z.string(), z.string()), + measurements: z.record(z.string(), z.number().finite()), + trace_id: z.string().optional(), + parent_event_id: z.string().optional(), + error: z + .object({ + message: z.string(), + type: z.string().optional(), + code: z.string().optional(), + }) + .optional(), +}); + +const ExportTelemetryEventSchema = StoredTelemetryEventSchema.transform( + (event): ExportTelemetryEvent => ({ + eventId: event.event_id, + eventName: event.event_name, + timestamp: event.timestamp, + eventSequence: event.event_sequence, + context: { + extensionVersion: event.context.extension_version, + machineId: event.context.machine_id, + sessionId: event.context.session_id, + osType: event.context.os_type, + osVersion: event.context.os_version, + hostArch: event.context.host_arch, + platformName: event.context.platform_name, + platformVersion: event.context.platform_version, + deploymentUrl: event.context.deployment_url, + }, + properties: event.properties, + measurements: event.measurements, + ...(event.trace_id !== undefined && { traceId: event.trace_id }), + ...(event.parent_event_id !== undefined && { + parentEventId: event.parent_event_id, + }), + ...(event.error !== undefined && { error: event.error }), + }), +); + +type StoredTelemetryEvent = z.infer; + +interface TelemetryFileCandidate { + readonly name: string; + readonly date: string; + readonly sessionSlug: string; + readonly segment: number; +} + +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) => telemetryFileCandidate(name)) + .filter( + (candidate): candidate is TelemetryFileCandidate => + candidate !== undefined && + fileDateCanContainRangeEvent(candidate.date, range), + ) + .sort(compareTelemetryFiles) + .map(({ name }) => path.join(telemetryDir, name)); +} + +function telemetryFileCandidate( + name: string, +): TelemetryFileCandidate | undefined { + const match = TELEMETRY_FILE_PATTERN.exec(name); + if (!match) { + return undefined; + } + return { + name, + date: match[1], + sessionSlug: match[2], + segment: match[3] === undefined ? 0 : Number(match[3]), + }; +} + +function compareTelemetryFiles( + a: TelemetryFileCandidate, + b: TelemetryFileCandidate, +): number { + return ( + a.date.localeCompare(b.date) || + a.sessionSlug.localeCompare(b.sessionSlug) || + a.segment - b.segment || + a.name.localeCompare(b.name) + ); +} + +export async function* readTelemetryEvents( + filePaths: readonly string[], + range: TelemetryDateRange, +): AsyncGenerator { + for (const filePath of filePaths) { + let lineNumber = 0; + const lines = readline.createInterface({ + input: createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + try { + for await (const line of lines) { + lineNumber += 1; + if (line.trim() === "") { + continue; + } + const event = parseStoredTelemetryEvent(line, filePath, lineNumber); + if (isTimestampInRange(event.timestamp, range)) { + yield event; + } + } + } catch (err) { + throw wrapReadError(err, filePath, lineNumber); + } + } +} + +export function parseStoredTelemetryEvent( + line: string, + filePath = "", + lineNumber = 1, +): ExportTelemetryEvent { + try { + return ExportTelemetryEventSchema.parse(JSON.parse(line)); + } catch (err) { + throw new Error( + `Failed to parse telemetry file ${path.basename(filePath)}:${lineNumber}: ${errorMessage(err)}`, + { cause: err }, + ); + } +} + +export function toStoredTelemetryEvent( + event: ExportTelemetryEvent, +): StoredTelemetryEvent { + 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 }), + }; +} + +function wrapReadError( + err: unknown, + filePath: string, + lineNumber: number, +): Error { + if (err instanceof Error && err.message.includes(path.basename(filePath))) { + return err; + } + const location = lineNumber > 0 ? `:${lineNumber}` : ""; + return new Error( + `Failed to read telemetry file ${path.basename(filePath)}${location}: ${errorMessage(err)}`, + { cause: err }, + ); +} + +function errorMessage(err: unknown): string { + return err instanceof z.ZodError + ? z.prettifyError(err) + : err instanceof Error + ? err.message + : String(err); +} diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts new file mode 100644 index 000000000..601dec6fb --- /dev/null +++ b/src/telemetry/export/range.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const UtcDateSchema = z.iso.date(); + +export type TelemetryRangePresetId = + | "last24Hours" + | "last7Days" + | "last30Days" + | "allTime"; + +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 const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = [ + { + id: "last24Hours", + label: "Last 24 hours", + detail: "Export telemetry from the last day.", + }, + { + id: "last7Days", + label: "Last 7 days", + detail: "Export telemetry from the last week.", + }, + { + id: "last30Days", + label: "Last 30 days", + detail: "Export telemetry from the last month.", + }, + { + id: "allTime", + label: "All time", + detail: "Export all stored telemetry.", + }, +]; + +export function createPresetDateRange( + id: TelemetryRangePresetId, + now: Date = new Date(), +): TelemetryDateRange { + const endMs = now.getTime(); + switch (id) { + case "last24Hours": + return { + label: "Last 24 hours", + filenamePart: "last-24-hours", + startMs: endMs - DAY_MS, + endMs, + }; + case "last7Days": + return { + label: "Last 7 days", + filenamePart: "last-7-days", + startMs: endMs - 7 * DAY_MS, + endMs, + }; + case "last30Days": + return { + label: "Last 30 days", + filenamePart: "last-30-days", + startMs: endMs - 30 * DAY_MS, + endMs, + }; + case "allTime": + return { + label: "All time", + filenamePart: "all-time", + }; + } +} + +export function createCustomDateRange( + startDate: string, + endDate: string, +): TelemetryDateRange { + const startMs = parseUtcDate(startDate); + const endStartMs = parseUtcDate(endDate); + if (endStartMs < startMs) { + throw new Error("End date must be on or after start date."); + } + return { + label: `${startDate} to ${endDate}`, + filenamePart: `${startDate}_to_${endDate}`, + startMs, + endMs: endStartMs + DAY_MS, + }; +} + +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."; +} + +export function parseUtcDate(value: string): number { + try { + const [year, month, day] = UtcDateSchema.parse(value) + .split("-") + .map(Number); + return Date.UTC(year, month - 1, day); + } catch (err) { + throw new Error(`Invalid date '${value}'. Use YYYY-MM-DD.`, { cause: err }); + } +} + +export function utcDateString(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +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) + ); +} + +export function fileDateCanContainRangeEvent( + date: string, + range: TelemetryDateRange, +): boolean { + if (range.startMs === undefined && range.endMs === undefined) { + return true; + } + const startDate = + range.startMs === undefined ? undefined : utcDateString(range.startMs); + const endDate = + range.endMs === undefined + ? undefined + : utcDateString(range.endMs - 1 + DAY_MS); + return ( + (startDate === undefined || date >= startDate) && + (endDate === undefined || date <= endDate) + ); +} diff --git a/src/telemetry/export/types.ts b/src/telemetry/export/types.ts new file mode 100644 index 000000000..aa779133e --- /dev/null +++ b/src/telemetry/export/types.ts @@ -0,0 +1,9 @@ +import type { TelemetryEvent } from "../event"; + +export type ExportTelemetryEvent = TelemetryEvent; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { readonly [key: string]: JsonValue }; diff --git a/test/unit/telemetry/export/files.test.ts b/test/unit/telemetry/export/files.test.ts new file mode 100644 index 000000000..97002682b --- /dev/null +++ b/test/unit/telemetry/export/files.test.ts @@ -0,0 +1,193 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + listTelemetryFilesForRange, + parseStoredTelemetryEvent, + readTelemetryEvents, + toStoredTelemetryEvent, +} from "@/telemetry/export/files"; +import { createCustomDateRange } from "@/telemetry/export/range"; + +import type { ExportTelemetryEvent } from "@/telemetry/export/types"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telemetry-export-files-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("telemetry export files", () => { + it("filters telemetry files by the date in the filename", async () => { + await Promise.all([ + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-11-aaaaaaaa.jsonl"), + "", + ), + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-12-bbbbbbbb.jsonl"), + "", + ), + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-12-bbbbbbbb.1.jsonl"), + "", + ), + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-14-cccccccc.jsonl"), + "", + ), + fs.writeFile(path.join(tmpDir, "notes-2026-05-12.jsonl"), ""), + ]); + + const files = await listTelemetryFilesForRange( + tmpDir, + createCustomDateRange("2026-05-12", "2026-05-13"), + ); + + expect(files.map((file) => path.basename(file))).toEqual([ + "telemetry-2026-05-12-bbbbbbbb.jsonl", + "telemetry-2026-05-12-bbbbbbbb.1.jsonl", + "telemetry-2026-05-14-cccccccc.jsonl", + ]); + }); + + it("returns an empty list when the telemetry directory does not exist", async () => { + await expect( + listTelemetryFilesForRange( + path.join(tmpDir, "missing"), + createCustomDateRange("2026-05-12", "2026-05-13"), + ), + ).resolves.toEqual([]); + }); + + it("parses stored snake case telemetry into export events", () => { + const parsed = parseStoredTelemetryEvent( + JSON.stringify(toStoredTelemetryEvent(makeEvent({ eventName: "log" }))), + ); + + expect(parsed).toMatchObject({ + eventId: "1111111111111111", + eventName: "log", + context: { + extensionVersion: "1.2.3", + deploymentUrl: "https://coder.example.com", + }, + }); + }); + + it("includes the day after the range so buffered events are not missed", async () => { + await Promise.all([ + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"), + "", + ), + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-13-aaaaaaaa.jsonl"), + "", + ), + fs.writeFile( + path.join(tmpDir, "telemetry-2026-05-14-aaaaaaaa.jsonl"), + "", + ), + ]); + + const files = await listTelemetryFilesForRange( + tmpDir, + createCustomDateRange("2026-05-12", "2026-05-12"), + ); + + expect(files.map((file) => path.basename(file))).toEqual([ + "telemetry-2026-05-12-aaaaaaaa.jsonl", + "telemetry-2026-05-13-aaaaaaaa.jsonl", + ]); + }); + + it("streams events and filters by exact timestamp range", async () => { + const filePath = path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"); + await fs.writeFile( + filePath, + [ + toStoredTelemetryEvent( + makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" }), + ), + toStoredTelemetryEvent( + makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" }), + ), + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + + const events: ExportTelemetryEvent[] = []; + for await (const event of readTelemetryEvents( + [filePath], + 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("fails when a telemetry timestamp is not a valid ISO date time", () => { + expect(() => + parseStoredTelemetryEvent( + JSON.stringify( + toStoredTelemetryEvent( + makeEvent({ timestamp: "2026-02-30T00:00:00.000Z" }), + ), + ), + ), + ).toThrow(/Failed to parse telemetry file/); + }); + + it("fails when a JSONL line is corrupt", async () => { + const filePath = path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"); + await fs.writeFile(filePath, "{not-json}\n"); + + const read = async (): Promise => { + for await (const _event of readTelemetryEvents( + [filePath], + createCustomDateRange("2026-05-12", "2026-05-12"), + )) { + // Drain iterator. + } + }; + + await expect(read()).rejects.toThrow( + /Failed to parse telemetry file telemetry-2026-05-12-aaaaaaaa\.jsonl:1/, + ); + }); +}); + +function makeEvent( + overrides: Partial, +): ExportTelemetryEvent { + return { + eventId: "1111111111111111", + eventName: "test.event", + timestamp: "2026-05-12T12:00:00.000Z", + eventSequence: 1, + context: { + extensionVersion: "1.2.3", + machineId: "machine", + sessionId: "session", + osType: "linux", + osVersion: "6.0.0", + hostArch: "x64", + platformName: "VS Code", + platformVersion: "1.100.0", + deploymentUrl: "https://coder.example.com", + }, + properties: {}, + measurements: {}, + ...overrides, + }; +} diff --git a/test/unit/telemetry/export/range.test.ts b/test/unit/telemetry/export/range.test.ts new file mode 100644 index 000000000..79a96dcd1 --- /dev/null +++ b/test/unit/telemetry/export/range.test.ts @@ -0,0 +1,57 @@ +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(true); + expect(fileDateCanContainRangeEvent("2026-05-15", 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); + }); +}); From 2696e08dcf43b9088b813364b13ea4260b002155 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 18 May 2026 21:48:44 +0300 Subject: [PATCH 2/4] refactor(telemetry): simplify export range presets and drop pass-through types - Consolidate preset labels/details/filenames/durations into one PRESETS record so TELEMETRY_RANGE_PRESETS and createPresetDateRange stay in sync. - Derive TelemetryRangePresetId from PRESET_IDS; un-export parseUtcDate and utcDateString (used only internally). - Drop src/telemetry/export/types.ts; consumers import TelemetryEvent directly. JsonValue/JsonPrimitive are unused here and will be introduced by the OTLP writer branch where they're actually needed. - Flatten the nested ternary in errorMessage. --- src/telemetry/export/files.ts | 26 +++--- src/telemetry/export/range.ts | 107 +++++++++++------------ src/telemetry/export/types.ts | 9 -- test/unit/telemetry/export/files.test.ts | 8 +- 4 files changed, 70 insertions(+), 80 deletions(-) delete mode 100644 src/telemetry/export/types.ts diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts index 59dde10a1..2790780f5 100644 --- a/src/telemetry/export/files.ts +++ b/src/telemetry/export/files.ts @@ -10,7 +10,7 @@ import { type TelemetryDateRange, } from "./range"; -import type { ExportTelemetryEvent } from "./types"; +import type { TelemetryEvent } from "../event"; const TELEMETRY_FILE_PATTERN = /^telemetry-(\d{4}-\d{2}-\d{2})-([a-zA-Z0-9]+)(?:\.(\d+))?\.jsonl$/; @@ -44,8 +44,8 @@ const StoredTelemetryEventSchema = z.object({ .optional(), }); -const ExportTelemetryEventSchema = StoredTelemetryEventSchema.transform( - (event): ExportTelemetryEvent => ({ +const TelemetryEventSchema = StoredTelemetryEventSchema.transform( + (event): TelemetryEvent => ({ eventId: event.event_id, eventName: event.event_name, timestamp: event.timestamp, @@ -135,7 +135,7 @@ function compareTelemetryFiles( export async function* readTelemetryEvents( filePaths: readonly string[], range: TelemetryDateRange, -): AsyncGenerator { +): AsyncGenerator { for (const filePath of filePaths) { let lineNumber = 0; const lines = readline.createInterface({ @@ -163,9 +163,9 @@ export function parseStoredTelemetryEvent( line: string, filePath = "", lineNumber = 1, -): ExportTelemetryEvent { +): TelemetryEvent { try { - return ExportTelemetryEventSchema.parse(JSON.parse(line)); + return TelemetryEventSchema.parse(JSON.parse(line)); } catch (err) { throw new Error( `Failed to parse telemetry file ${path.basename(filePath)}:${lineNumber}: ${errorMessage(err)}`, @@ -175,7 +175,7 @@ export function parseStoredTelemetryEvent( } export function toStoredTelemetryEvent( - event: ExportTelemetryEvent, + event: TelemetryEvent, ): StoredTelemetryEvent { return { event_id: event.eventId, @@ -219,9 +219,11 @@ function wrapReadError( } function errorMessage(err: unknown): string { - return err instanceof z.ZodError - ? z.prettifyError(err) - : err instanceof Error - ? err.message - : String(err); + if (err instanceof z.ZodError) { + return z.prettifyError(err); + } + if (err instanceof Error) { + return err.message; + } + return String(err); } diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts index 601dec6fb..166bf1bcb 100644 --- a/src/telemetry/export/range.ts +++ b/src/telemetry/export/range.ts @@ -3,81 +3,79 @@ import { z } from "zod"; const DAY_MS = 24 * 60 * 60 * 1000; const UtcDateSchema = z.iso.date(); -export type TelemetryRangePresetId = - | "last24Hours" - | "last7Days" - | "last30Days" - | "allTime"; - -export interface TelemetryDateRange { +interface PresetConfig { readonly label: string; + readonly detail: string; readonly filenamePart: string; - readonly startMs?: number; - readonly endMs?: number; + readonly durationMs: number | undefined; } -export interface TelemetryRangePreset { - readonly id: TelemetryRangePresetId; - readonly label: string; - readonly detail: string; -} +const PRESET_IDS = [ + "last24Hours", + "last7Days", + "last30Days", + "allTime", +] as const; + +export type TelemetryRangePresetId = (typeof PRESET_IDS)[number]; -export const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = [ - { - id: "last24Hours", +const PRESETS: Record = { + last24Hours: { label: "Last 24 hours", detail: "Export telemetry from the last day.", + filenamePart: "last-24-hours", + durationMs: DAY_MS, }, - { - id: "last7Days", + last7Days: { label: "Last 7 days", detail: "Export telemetry from the last week.", + filenamePart: "last-7-days", + durationMs: 7 * DAY_MS, }, - { - id: "last30Days", + last30Days: { label: "Last 30 days", detail: "Export telemetry from the last month.", + filenamePart: "last-30-days", + durationMs: 30 * DAY_MS, }, - { - id: "allTime", + allTime: { label: "All time", detail: "Export all stored telemetry.", + filenamePart: "all-time", + durationMs: undefined, }, -]; +}; + +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 const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = + PRESET_IDS.map((id) => ({ + id, + label: PRESETS[id].label, + detail: PRESETS[id].detail, + })); export function createPresetDateRange( id: TelemetryRangePresetId, now: Date = new Date(), ): TelemetryDateRange { - const endMs = now.getTime(); - switch (id) { - case "last24Hours": - return { - label: "Last 24 hours", - filenamePart: "last-24-hours", - startMs: endMs - DAY_MS, - endMs, - }; - case "last7Days": - return { - label: "Last 7 days", - filenamePart: "last-7-days", - startMs: endMs - 7 * DAY_MS, - endMs, - }; - case "last30Days": - return { - label: "Last 30 days", - filenamePart: "last-30-days", - startMs: endMs - 30 * DAY_MS, - endMs, - }; - case "allTime": - return { - label: "All time", - filenamePart: "all-time", - }; + const { label, filenamePart, durationMs } = PRESETS[id]; + if (durationMs === undefined) { + return { label, filenamePart }; } + const endMs = now.getTime(); + return { label, filenamePart, startMs: endMs - durationMs, endMs }; } export function createCustomDateRange( @@ -106,7 +104,7 @@ export function validateUtcDateInput(value: string): string | undefined { : "Enter a valid calendar date."; } -export function parseUtcDate(value: string): number { +function parseUtcDate(value: string): number { try { const [year, month, day] = UtcDateSchema.parse(value) .split("-") @@ -117,7 +115,7 @@ export function parseUtcDate(value: string): number { } } -export function utcDateString(ms: number): string { +function utcDateString(ms: number): string { return new Date(ms).toISOString().slice(0, 10); } @@ -144,6 +142,7 @@ export function fileDateCanContainRangeEvent( } const startDate = range.startMs === undefined ? undefined : utcDateString(range.startMs); + // One-day forward grace for events buffered past midnight. const endDate = range.endMs === undefined ? undefined diff --git a/src/telemetry/export/types.ts b/src/telemetry/export/types.ts deleted file mode 100644 index aa779133e..000000000 --- a/src/telemetry/export/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { TelemetryEvent } from "../event"; - -export type ExportTelemetryEvent = TelemetryEvent; - -export type JsonPrimitive = string | number | boolean | null; -export type JsonValue = - | JsonPrimitive - | JsonValue[] - | { readonly [key: string]: JsonValue }; diff --git a/test/unit/telemetry/export/files.test.ts b/test/unit/telemetry/export/files.test.ts index 97002682b..b58e39ba4 100644 --- a/test/unit/telemetry/export/files.test.ts +++ b/test/unit/telemetry/export/files.test.ts @@ -11,7 +11,7 @@ import { } from "@/telemetry/export/files"; import { createCustomDateRange } from "@/telemetry/export/range"; -import type { ExportTelemetryEvent } from "@/telemetry/export/types"; +import type { TelemetryEvent } from "@/telemetry/event"; let tmpDir: string; @@ -124,7 +124,7 @@ describe("telemetry export files", () => { .join("\n") + "\n", ); - const events: ExportTelemetryEvent[] = []; + const events: TelemetryEvent[] = []; for await (const event of readTelemetryEvents( [filePath], createCustomDateRange("2026-05-12", "2026-05-12"), @@ -167,9 +167,7 @@ describe("telemetry export files", () => { }); }); -function makeEvent( - overrides: Partial, -): ExportTelemetryEvent { +function makeEvent(overrides: Partial): TelemetryEvent { return { eventId: "1111111111111111", eventName: "test.event", From aca8405dcd9357cfa574ad327f9d53f3317ca412 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 19 May 2026 12:58:12 +0300 Subject: [PATCH 3/4] refactor(telemetry): unify wire format and tighten export contract Extract `src/telemetry/wireFormat.ts` as the single source of truth for the on-disk JSONL shape. `SessionContext`, `TelemetryContext`, and `TelemetryEvent` are now `DeepReadonly>` derived from the schemas, removing the parallel hand-written interfaces and the satisfies drift checks. The sink and exporter both share `serializeTelemetryEvent` and `parseTelemetryEventLine`, so the format can't drift between writer and reader. Export-side cleanups in `files.ts` and `range.ts`: hide internal helpers, rename `segment`/`sessionSlug` to `part`/`session`, drop the next-day grace in `fileDateCanContainRangeEvent`, simplify presets, and replace the manual snake/camel mapping with a tiny generic rename. Tests for the parse boundary moved to `wireFormat.test.ts`; the remaining files tests focus on listing and streaming, now backed by memfs. --- src/telemetry/event.ts | 49 +--- src/telemetry/export/files.ts | 228 +++++------------- src/telemetry/export/range.ts | 97 ++++---- src/telemetry/sinks/localJsonlSink.ts | 28 +-- src/telemetry/wireFormat.ts | 144 +++++++++++ test/mocks/telemetry.ts | 33 +++ test/unit/telemetry/export/files.test.ts | 212 ++++++---------- test/unit/telemetry/export/range.test.ts | 3 +- .../telemetry/sinks/localJsonlSink.test.ts | 32 +-- test/unit/telemetry/wireFormat.test.ts | 123 ++++++++++ 10 files changed, 493 insertions(+), 456 deletions(-) create mode 100644 src/telemetry/wireFormat.ts create mode 100644 test/unit/telemetry/wireFormat.test.ts diff --git a/src/telemetry/event.ts b/src/telemetry/event.ts index 1749a068a..7de8ad8c0 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 index 2790780f5..a8c404069 100644 --- a/src/telemetry/export/files.ts +++ b/src/telemetry/export/files.ts @@ -2,7 +2,12 @@ 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 { z } from "zod"; + +import { toError } from "../../error/errorUtils"; +import { + parseTelemetryEventLine, + TelemetryFileParseError, +} from "../wireFormat"; import { fileDateCanContainRangeEvent, @@ -12,74 +17,22 @@ import { import type { TelemetryEvent } from "../event"; -const TELEMETRY_FILE_PATTERN = - /^telemetry-(\d{4}-\d{2}-\d{2})-([a-zA-Z0-9]+)(?:\.(\d+))?\.jsonl$/; - -const StoredTelemetryEventSchema = z.object({ - event_id: z.string(), - event_name: z.string(), - timestamp: z.iso.datetime({ offset: true }), - event_sequence: z.number().finite(), - context: z.object({ - extension_version: z.string(), - machine_id: z.string(), - session_id: z.string(), - os_type: z.string(), - os_version: z.string(), - host_arch: z.string(), - platform_name: z.string(), - platform_version: z.string(), - deployment_url: z.string(), - }), - properties: z.record(z.string(), z.string()), - measurements: z.record(z.string(), z.number().finite()), - trace_id: z.string().optional(), - parent_event_id: z.string().optional(), - error: z - .object({ - message: z.string(), - type: z.string().optional(), - code: z.string().optional(), - }) - .optional(), -}); - -const TelemetryEventSchema = StoredTelemetryEventSchema.transform( - (event): TelemetryEvent => ({ - eventId: event.event_id, - eventName: event.event_name, - timestamp: event.timestamp, - eventSequence: event.event_sequence, - context: { - extensionVersion: event.context.extension_version, - machineId: event.context.machine_id, - sessionId: event.context.session_id, - osType: event.context.os_type, - osVersion: event.context.os_version, - hostArch: event.context.host_arch, - platformName: event.context.platform_name, - platformVersion: event.context.platform_version, - deploymentUrl: event.context.deployment_url, - }, - properties: event.properties, - measurements: event.measurements, - ...(event.trace_id !== undefined && { traceId: event.trace_id }), - ...(event.parent_event_id !== undefined && { - parentEventId: event.parent_event_id, - }), - ...(event.error !== undefined && { error: event.error }), - }), -); - -type StoredTelemetryEvent = z.infer; - -interface TelemetryFileCandidate { - readonly name: string; +interface TelemetryLogFile { + readonly path: string; readonly date: string; - readonly sessionSlug: string; - readonly segment: number; + 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, @@ -95,135 +48,78 @@ export async function listTelemetryFilesForRange( } return names - .map((name) => telemetryFileCandidate(name)) + .map((name) => parseLogFilename(telemetryDir, name)) .filter( - (candidate): candidate is TelemetryFileCandidate => - candidate !== undefined && - fileDateCanContainRangeEvent(candidate.date, range), + (file): file is TelemetryLogFile => + file !== undefined && fileDateCanContainRangeEvent(file.date, range), ) - .sort(compareTelemetryFiles) - .map(({ name }) => path.join(telemetryDir, name)); -} - -function telemetryFileCandidate( - name: string, -): TelemetryFileCandidate | undefined { - const match = TELEMETRY_FILE_PATTERN.exec(name); - if (!match) { - return undefined; - } - return { - name, - date: match[1], - sessionSlug: match[2], - segment: match[3] === undefined ? 0 : Number(match[3]), - }; + .sort(compareLogFiles) + .map(({ path: filePath }) => filePath); } -function compareTelemetryFiles( - a: TelemetryFileCandidate, - b: TelemetryFileCandidate, -): number { - return ( - a.date.localeCompare(b.date) || - a.sessionSlug.localeCompare(b.sessionSlug) || - a.segment - b.segment || - a.name.localeCompare(b.name) - ); -} - -export async function* readTelemetryEvents( +/** + * 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, -): AsyncGenerator { +): AsyncIterable { for (const filePath of filePaths) { - let lineNumber = 0; + const name = path.basename(filePath); + const stream = createReadStream(filePath, { encoding: "utf8" }); const lines = readline.createInterface({ - input: createReadStream(filePath, { encoding: "utf8" }), + input: stream, crlfDelay: Infinity, }); + let lineNumber = 0; try { for await (const line of lines) { lineNumber += 1; if (line.trim() === "") { continue; } - const event = parseStoredTelemetryEvent(line, filePath, lineNumber); + const event = parseTelemetryEventLine(line, name, lineNumber); if (isTimestampInRange(event.timestamp, range)) { yield event; } } } catch (err) { - throw wrapReadError(err, filePath, lineNumber); + 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 { + lines.close(); + stream.destroy(); } } } -export function parseStoredTelemetryEvent( - line: string, - filePath = "", - lineNumber = 1, -): TelemetryEvent { - try { - return TelemetryEventSchema.parse(JSON.parse(line)); - } catch (err) { - throw new Error( - `Failed to parse telemetry file ${path.basename(filePath)}:${lineNumber}: ${errorMessage(err)}`, - { cause: err }, - ); +function parseLogFilename( + dir: string, + name: string, +): TelemetryLogFile | undefined { + const match = TELEMETRY_FILE_PATTERN.exec(name); + if (!match) { + return undefined; } -} - -export function toStoredTelemetryEvent( - event: TelemetryEvent, -): StoredTelemetryEvent { 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 }), + path: path.join(dir, name), + date: match[1], + session: match[2], + part: match[3] === undefined ? 0 : Number(match[3]), }; } -function wrapReadError( - err: unknown, - filePath: string, - lineNumber: number, -): Error { - if (err instanceof Error && err.message.includes(path.basename(filePath))) { - return err; - } - const location = lineNumber > 0 ? `:${lineNumber}` : ""; - return new Error( - `Failed to read telemetry file ${path.basename(filePath)}${location}: ${errorMessage(err)}`, - { cause: err }, +function compareLogFiles(a: TelemetryLogFile, b: TelemetryLogFile): number { + return ( + a.date.localeCompare(b.date) || + a.session.localeCompare(b.session) || + a.part - b.part ); } - -function errorMessage(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/src/telemetry/export/range.ts b/src/telemetry/export/range.ts index 166bf1bcb..e73e1c6dd 100644 --- a/src/telemetry/export/range.ts +++ b/src/telemetry/export/range.ts @@ -3,23 +3,27 @@ import { z } from "zod"; const DAY_MS = 24 * 60 * 60 * 1000; const UtcDateSchema = z.iso.date(); -interface PresetConfig { +/** + * 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 detail: string; readonly filenamePart: string; - readonly durationMs: number | undefined; + readonly startMs?: number; + readonly endMs?: number; } -const PRESET_IDS = [ - "last24Hours", - "last7Days", - "last30Days", - "allTime", -] as const; +export interface TelemetryRangePreset { + readonly id: TelemetryRangePresetId; + readonly label: string; + readonly detail: string; +} -export type TelemetryRangePresetId = (typeof PRESET_IDS)[number]; +export type TelemetryRangePresetId = keyof typeof PRESETS; -const PRESETS: Record = { +const PRESETS = { last24Hours: { label: "Last 24 hours", detail: "Export telemetry from the last day.", @@ -44,28 +48,17 @@ const PRESETS: Record = { filenamePart: "all-time", durationMs: undefined, }, -}; - -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; -} +} as const; +/** Presets the export UI shows, in display order. */ export const TELEMETRY_RANGE_PRESETS: readonly TelemetryRangePreset[] = - PRESET_IDS.map((id) => ({ - id, - label: PRESETS[id].label, - detail: PRESETS[id].detail, + 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(), @@ -78,6 +71,10 @@ export function createPresetDateRange( 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, @@ -95,6 +92,7 @@ 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."; @@ -104,21 +102,7 @@ export function validateUtcDateInput(value: string): string | undefined { : "Enter a valid calendar date."; } -function parseUtcDate(value: string): number { - try { - const [year, month, day] = UtcDateSchema.parse(value) - .split("-") - .map(Number); - return Date.UTC(year, month - 1, day); - } catch (err) { - throw new Error(`Invalid date '${value}'. Use YYYY-MM-DD.`, { cause: err }); - } -} - -function utcDateString(ms: number): string { - return new Date(ms).toISOString().slice(0, 10); -} - +/** True if the ISO `timestamp` falls inside the range. */ export function isTimestampInRange( timestamp: string, range: TelemetryDateRange, @@ -133,22 +117,33 @@ export function isTimestampInRange( ); } +/** + * 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 { - if (range.startMs === undefined && range.endMs === undefined) { - return true; - } const startDate = range.startMs === undefined ? undefined : utcDateString(range.startMs); - // One-day forward grace for events buffered past midnight. const endDate = - range.endMs === undefined - ? undefined - : utcDateString(range.endMs - 1 + DAY_MS); + 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 5ac7fbb3a..8be84ccd8 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 { serializeTelemetryEvent } from "../wireFormat"; import type { Logger } from "../../logging/logger"; import type { TelemetryEvent, TelemetryLevel, TelemetrySink } from "../event"; @@ -315,30 +316,5 @@ function toSessionSlug(sessionId: string): string { } 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" - ); + return JSON.stringify(serializeTelemetryEvent(event)) + "\n"; } diff --git a/src/telemetry/wireFormat.ts b/src/telemetry/wireFormat.ts new file mode 100644 index 000000000..cda5f74f5 --- /dev/null +++ b/src/telemetry/wireFormat.ts @@ -0,0 +1,144 @@ +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"; + } +} + +/** 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 3d954d32c..f0c46b91f 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 index b58e39ba4..fc6cc6a2d 100644 --- a/test/unit/telemetry/export/files.test.ts +++ b/test/unit/telemetry/export/files.test.ts @@ -1,132 +1,86 @@ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; +import { vol } from "memfs"; import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listTelemetryFilesForRange, - parseStoredTelemetryEvent, - readTelemetryEvents, - toStoredTelemetryEvent, + streamTelemetryEvents, } from "@/telemetry/export/files"; import { createCustomDateRange } from "@/telemetry/export/range"; +import { serializeTelemetryEvent } from "@/telemetry/wireFormat"; + +import { createTelemetryEventFactory } from "../../../mocks/telemetry"; + +import type * as fs from "node:fs"; import type { TelemetryEvent } from "@/telemetry/event"; -let tmpDir: string; +vi.mock("node:fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs.promises; +}); -beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "telemetry-export-files-")); +vi.mock("node:fs", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs; }); -afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); +const DIR = "/telemetry"; + +let makeEvent: ReturnType; + +beforeEach(() => { + vol.reset(); + vol.mkdirSync(DIR, { recursive: true }); + makeEvent = createTelemetryEventFactory(); }); -describe("telemetry export files", () => { - it("filters telemetry files by the date in the filename", async () => { - await Promise.all([ - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-11-aaaaaaaa.jsonl"), - "", - ), - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-12-bbbbbbbb.jsonl"), - "", - ), - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-12-bbbbbbbb.1.jsonl"), - "", - ), - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-14-cccccccc.jsonl"), - "", - ), - fs.writeFile(path.join(tmpDir, "notes-2026-05-12.jsonl"), ""), - ]); +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( - tmpDir, + DIR, createCustomDateRange("2026-05-12", "2026-05-13"), ); - expect(files.map((file) => path.basename(file))).toEqual([ + expect(files.map((p) => path.basename(p))).toEqual([ "telemetry-2026-05-12-bbbbbbbb.jsonl", "telemetry-2026-05-12-bbbbbbbb.1.jsonl", - "telemetry-2026-05-14-cccccccc.jsonl", ]); }); - it("returns an empty list when the telemetry directory does not exist", async () => { + it("returns an empty list when the telemetry directory is missing", async () => { await expect( listTelemetryFilesForRange( - path.join(tmpDir, "missing"), + `${DIR}/missing`, createCustomDateRange("2026-05-12", "2026-05-13"), ), ).resolves.toEqual([]); }); +}); - it("parses stored snake case telemetry into export events", () => { - const parsed = parseStoredTelemetryEvent( - JSON.stringify(toStoredTelemetryEvent(makeEvent({ eventName: "log" }))), - ); - - expect(parsed).toMatchObject({ - eventId: "1111111111111111", - eventName: "log", - context: { - extensionVersion: "1.2.3", - deploymentUrl: "https://coder.example.com", - }, +describe("streamTelemetryEvents", () => { + it("yields only events whose timestamp falls inside the range", async () => { + writeFiles({ + "telemetry-2026-05-12-aaaaaaaa.jsonl": + wireLine(makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" })) + + wireLine(makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" })), }); - }); - - it("includes the day after the range so buffered events are not missed", async () => { - await Promise.all([ - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"), - "", - ), - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-13-aaaaaaaa.jsonl"), - "", - ), - fs.writeFile( - path.join(tmpDir, "telemetry-2026-05-14-aaaaaaaa.jsonl"), - "", - ), - ]); - - const files = await listTelemetryFilesForRange( - tmpDir, - createCustomDateRange("2026-05-12", "2026-05-12"), - ); - - expect(files.map((file) => path.basename(file))).toEqual([ - "telemetry-2026-05-12-aaaaaaaa.jsonl", - "telemetry-2026-05-13-aaaaaaaa.jsonl", - ]); - }); - - it("streams events and filters by exact timestamp range", async () => { - const filePath = path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"); - await fs.writeFile( - filePath, - [ - toStoredTelemetryEvent( - makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" }), - ), - toStoredTelemetryEvent( - makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" }), - ), - ] - .map((event) => JSON.stringify(event)) - .join("\n") + "\n", - ); const events: TelemetryEvent[] = []; - for await (const event of readTelemetryEvents( - [filePath], + for await (const event of streamTelemetryEvents( + [`${DIR}/telemetry-2026-05-12-aaaaaaaa.jsonl`], createCustomDateRange("2026-05-12", "2026-05-12"), )) { events.push(event); @@ -136,56 +90,32 @@ describe("telemetry export files", () => { expect(events[0].timestamp).toBe("2026-05-12T00:00:00.000Z"); }); - it("fails when a telemetry timestamp is not a valid ISO date time", () => { - expect(() => - parseStoredTelemetryEvent( - JSON.stringify( - toStoredTelemetryEvent( - makeEvent({ timestamp: "2026-02-30T00:00:00.000Z" }), - ), - ), - ), - ).toThrow(/Failed to parse telemetry file/); - }); - - it("fails when a JSONL line is corrupt", async () => { - const filePath = path.join(tmpDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"); - await fs.writeFile(filePath, "{not-json}\n"); - - const read = async (): Promise => { - for await (const _event of readTelemetryEvents( - [filePath], - createCustomDateRange("2026-05-12", "2026-05-12"), - )) { - // Drain iterator. - } - }; + it("surfaces parse errors with file:line context", async () => { + writeFiles({ + "telemetry-2026-05-12-aaaaaaaa.jsonl": "{not-json}\n", + }); - await expect(read()).rejects.toThrow( + await expect(drain("telemetry-2026-05-12-aaaaaaaa.jsonl")).rejects.toThrow( /Failed to parse telemetry file telemetry-2026-05-12-aaaaaaaa\.jsonl:1/, ); }); }); -function makeEvent(overrides: Partial): TelemetryEvent { - return { - eventId: "1111111111111111", - eventName: "test.event", - timestamp: "2026-05-12T12:00:00.000Z", - eventSequence: 1, - context: { - extensionVersion: "1.2.3", - machineId: "machine", - sessionId: "session", - osType: "linux", - osVersion: "6.0.0", - hostArch: "x64", - platformName: "VS Code", - platformVersion: "1.100.0", - deploymentUrl: "https://coder.example.com", - }, - properties: {}, - measurements: {}, - ...overrides, - }; +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. + } +} + +function wireLine(event: TelemetryEvent): string { + return JSON.stringify(serializeTelemetryEvent(event)) + "\n"; } diff --git a/test/unit/telemetry/export/range.test.ts b/test/unit/telemetry/export/range.test.ts index 79a96dcd1..46fc3960c 100644 --- a/test/unit/telemetry/export/range.test.ts +++ b/test/unit/telemetry/export/range.test.ts @@ -44,8 +44,7 @@ describe("telemetry export ranges", () => { 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(true); - expect(fileDateCanContainRangeEvent("2026-05-15", range)).toBe(false); + expect(fileDateCanContainRangeEvent("2026-05-14", range)).toBe(false); }); it("includes every filename date for all time", () => { diff --git a/test/unit/telemetry/sinks/localJsonlSink.test.ts b/test/unit/telemetry/sinks/localJsonlSink.test.ts index 745bac6c4..c2191f4a2 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 000000000..98cdc1300 --- /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 }); + }); +}); From ed104f0d1a9152def67ba4f42abe60d1094bf35c Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 20 May 2026 16:44:01 +0300 Subject: [PATCH 4/4] refactor(telemetry): address review nits on export module - Guarantee stream.destroy() in streamTelemetryEvents even if lines.close() throws. - Add serializeTelemetryEventLine in wireFormat.ts; sink and export test use it directly. - Rename createCustomDateRange intermediates to startDateMs / endDateMs for symmetry. --- src/telemetry/export/files.ts | 7 +++++-- src/telemetry/export/range.ts | 10 +++++----- src/telemetry/sinks/localJsonlSink.ts | 8 ++------ src/telemetry/wireFormat.ts | 5 +++++ test/unit/telemetry/export/files.test.ts | 14 +++++++------- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts index a8c404069..3f7d3e35b 100644 --- a/src/telemetry/export/files.ts +++ b/src/telemetry/export/files.ts @@ -94,8 +94,11 @@ export async function* streamTelemetryEvents( { cause: err }, ); } finally { - lines.close(); - stream.destroy(); + try { + lines.close(); + } finally { + stream.destroy(); + } } } } diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts index e73e1c6dd..52e46ac22 100644 --- a/src/telemetry/export/range.ts +++ b/src/telemetry/export/range.ts @@ -79,16 +79,16 @@ export function createCustomDateRange( startDate: string, endDate: string, ): TelemetryDateRange { - const startMs = parseUtcDate(startDate); - const endStartMs = parseUtcDate(endDate); - if (endStartMs < startMs) { + 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, - endMs: endStartMs + DAY_MS, + startMs: startDateMs, + endMs: endDateMs + DAY_MS, }; } diff --git a/src/telemetry/sinks/localJsonlSink.ts b/src/telemetry/sinks/localJsonlSink.ts index 8be84ccd8..b0d7e3ef9 100644 --- a/src/telemetry/sinks/localJsonlSink.ts +++ b/src/telemetry/sinks/localJsonlSink.ts @@ -12,7 +12,7 @@ import { cleanupFiles, type FileCleanupCandidate, } from "../../util/fileCleanup"; -import { serializeTelemetryEvent } from "../wireFormat"; +import { serializeTelemetryEventLine } from "../wireFormat"; import type { Logger } from "../../logging/logger"; import type { TelemetryEvent, TelemetryLevel, TelemetrySink } from "../event"; @@ -89,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; @@ -314,7 +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(serializeTelemetryEvent(event)) + "\n"; -} diff --git a/src/telemetry/wireFormat.ts b/src/telemetry/wireFormat.ts index cda5f74f5..69b71dbfd 100644 --- a/src/telemetry/wireFormat.ts +++ b/src/telemetry/wireFormat.ts @@ -62,6 +62,11 @@ export class TelemetryFileParseError extends Error { } } +/** 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, diff --git a/test/unit/telemetry/export/files.test.ts b/test/unit/telemetry/export/files.test.ts index fc6cc6a2d..2538edd30 100644 --- a/test/unit/telemetry/export/files.test.ts +++ b/test/unit/telemetry/export/files.test.ts @@ -7,7 +7,7 @@ import { streamTelemetryEvents, } from "@/telemetry/export/files"; import { createCustomDateRange } from "@/telemetry/export/range"; -import { serializeTelemetryEvent } from "@/telemetry/wireFormat"; +import { serializeTelemetryEventLine } from "@/telemetry/wireFormat"; import { createTelemetryEventFactory } from "../../../mocks/telemetry"; @@ -74,8 +74,12 @@ describe("streamTelemetryEvents", () => { it("yields only events whose timestamp falls inside the range", async () => { writeFiles({ "telemetry-2026-05-12-aaaaaaaa.jsonl": - wireLine(makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" })) + - wireLine(makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" })), + serializeTelemetryEventLine( + makeEvent({ timestamp: "2026-05-11T23:59:59.999Z" }), + ) + + serializeTelemetryEventLine( + makeEvent({ timestamp: "2026-05-12T00:00:00.000Z" }), + ), }); const events: TelemetryEvent[] = []; @@ -115,7 +119,3 @@ async function drain(name: string): Promise { // Pull the iterator to surface parse errors. } } - -function wireLine(event: TelemetryEvent): string { - return JSON.stringify(serializeTelemetryEvent(event)) + "\n"; -}