Skip to content

Commit eb776a7

Browse files
committed
feat(telemetry): add JSON export writer
1 parent 350d662 commit eb776a7

2 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/telemetry/export/writers.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { randomUUID } from "node:crypto";
2+
import * as fs from "node:fs/promises";
3+
import * as path from "node:path";
4+
5+
import { renameWithRetry } from "../../util";
6+
7+
import { toStoredTelemetryEvent } from "./files";
8+
9+
import type { ExportTelemetryEvent } from "./types";
10+
11+
export interface ExportCounts {
12+
readonly events: number;
13+
readonly logs: number;
14+
readonly traces: number;
15+
readonly metrics: number;
16+
}
17+
18+
class JsonEnvelopeWriter {
19+
readonly #filePath: string;
20+
readonly #suffix: string;
21+
#handle: fs.FileHandle | undefined;
22+
#count = 0;
23+
24+
private constructor(filePath: string, suffix: string) {
25+
this.#filePath = filePath;
26+
this.#suffix = suffix;
27+
}
28+
29+
public static async open(
30+
filePath: string,
31+
prefix: string,
32+
suffix: string,
33+
): Promise<JsonEnvelopeWriter> {
34+
const writer = new JsonEnvelopeWriter(filePath, suffix);
35+
writer.#handle = await fs.open(filePath, "w");
36+
try {
37+
await writer.#write(prefix);
38+
return writer;
39+
} catch (err) {
40+
await writer.close();
41+
throw err;
42+
}
43+
}
44+
45+
public get count(): number {
46+
return this.#count;
47+
}
48+
49+
public async write(value: unknown): Promise<void> {
50+
if (this.#count > 0) {
51+
await this.#write(",");
52+
}
53+
await this.#write(JSON.stringify(value));
54+
this.#count += 1;
55+
}
56+
57+
public async close(): Promise<void> {
58+
if (!this.#handle) {
59+
return;
60+
}
61+
try {
62+
await this.#write(this.#suffix);
63+
} finally {
64+
await this.#handle.close();
65+
this.#handle = undefined;
66+
}
67+
}
68+
69+
async #write(chunk: string): Promise<void> {
70+
if (!this.#handle) {
71+
throw new Error(`JSON writer for ${this.#filePath} is closed.`);
72+
}
73+
await this.#handle.writeFile(chunk, "utf8");
74+
}
75+
}
76+
77+
export async function writeJsonArrayExport(
78+
outputPath: string,
79+
events: AsyncIterable<ExportTelemetryEvent>,
80+
): Promise<ExportCounts> {
81+
return writeTempOutput(outputPath, async (tempPath) => {
82+
const writer = await JsonEnvelopeWriter.open(tempPath, "[\n", "\n]\n");
83+
let eventsWritten = 0;
84+
try {
85+
for await (const event of events) {
86+
await writer.write(toStoredTelemetryEvent(event));
87+
eventsWritten += 1;
88+
}
89+
} finally {
90+
await writer.close();
91+
}
92+
return {
93+
events: eventsWritten,
94+
logs: 0,
95+
traces: 0,
96+
metrics: 0,
97+
};
98+
});
99+
}
100+
101+
async function writeTempOutput<T>(
102+
outputPath: string,
103+
write: (tempPath: string) => Promise<T>,
104+
): Promise<T> {
105+
const parsed = path.parse(outputPath);
106+
const tempPath = path.join(
107+
parsed.dir,
108+
`.${parsed.name}.${process.pid}.${randomUUID()}.tmp${parsed.ext}`,
109+
);
110+
try {
111+
const result = await write(tempPath);
112+
await renameWithRetry(fs.rename, tempPath, outputPath);
113+
return result;
114+
} catch (err) {
115+
try {
116+
await fs.rm(tempPath, { force: true });
117+
} catch {
118+
// Keep the export failure as the error callers see.
119+
}
120+
throw err;
121+
}
122+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as fs from "node:fs/promises";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
6+
import { toStoredTelemetryEvent } from "@/telemetry/export/files";
7+
import { writeJsonArrayExport } from "@/telemetry/export/writers";
8+
9+
import type { ExportTelemetryEvent } from "@/telemetry/export/types";
10+
11+
let tmpDir: string;
12+
13+
beforeEach(async () => {
14+
tmpDir = await fs.mkdtemp(
15+
path.join(os.tmpdir(), "telemetry-export-writers-"),
16+
);
17+
});
18+
19+
afterEach(async () => {
20+
await fs.rm(tmpDir, { recursive: true, force: true });
21+
});
22+
23+
describe("telemetry export writers", () => {
24+
it("writes telemetry events as a JSON array using the stored event shape", async () => {
25+
const outputPath = path.join(tmpDir, "telemetry.json");
26+
27+
const events = [
28+
makeEvent({
29+
eventId: "1111111111111111",
30+
eventName: "first",
31+
properties: { result: "success" },
32+
measurements: { durationMs: 12 },
33+
traceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
34+
}),
35+
makeEvent({
36+
eventId: "2222222222222222",
37+
eventName: "second",
38+
parentEventId: "1111111111111111",
39+
error: { message: "boom", type: "Error" },
40+
}),
41+
];
42+
43+
const counts = await writeJsonArrayExport(outputPath, asyncEvents(events));
44+
45+
expect(counts.events).toBe(2);
46+
expect(JSON.parse(await fs.readFile(outputPath, "utf8"))).toEqual(
47+
events.map(toStoredTelemetryEvent),
48+
);
49+
});
50+
51+
it("writes a valid empty JSON array", async () => {
52+
const outputPath = path.join(tmpDir, "empty.json");
53+
54+
const counts = await writeJsonArrayExport(outputPath, asyncEvents([]));
55+
56+
expect(counts.events).toBe(0);
57+
expect(JSON.parse(await fs.readFile(outputPath, "utf8"))).toEqual([]);
58+
});
59+
});
60+
61+
async function* asyncEvents(
62+
events: readonly ExportTelemetryEvent[],
63+
): AsyncGenerator<ExportTelemetryEvent> {
64+
for (const event of events) {
65+
await Promise.resolve();
66+
yield event;
67+
}
68+
}
69+
70+
function makeEvent(
71+
overrides: Partial<ExportTelemetryEvent>,
72+
): ExportTelemetryEvent {
73+
return {
74+
eventId: "1111111111111111",
75+
eventName: "test.event",
76+
timestamp: "2026-05-12T12:00:00.000Z",
77+
eventSequence: 1,
78+
context: {
79+
extensionVersion: "1.2.3",
80+
machineId: "machine",
81+
sessionId: "session",
82+
osType: "linux",
83+
osVersion: "6.0.0",
84+
hostArch: "x64",
85+
platformName: "VS Code",
86+
platformVersion: "1.100.0",
87+
deploymentUrl: "https://coder.example.com",
88+
},
89+
properties: {},
90+
measurements: {},
91+
...overrides,
92+
};
93+
}

0 commit comments

Comments
 (0)