Skip to content

Commit addc6b9

Browse files
committed
fix(telemetry): address OTLP export writer review findings
- Stamp coder.event.{extension_version,session_id,deployment_url} on every log/span/metric record so multi-session and multi-deployment exports preserve the original producer's identity. The resource block remains an export-tool snapshot. - Stage envelopes in os.tmpdir() instead of next to the user's save path so cloud-sync agents do not ingest the intermediate uncompressed logs.json/traces.json/metrics.json. - Replace the in-memory fflate.zip with streaming Zip + ZipDeflate so multi-GB exports do not hold every envelope plus the zipped output in the V8 heap at once. - Forward staging-cleanup failures via OtlpWriteOptions.onStagingCleanupError instead of letting a Windows EBUSY in finally mask an otherwise-successful export (which writeAtomically would then erase via temp-file cleanup). - Increment metric channel counts per event rather than per non-empty record batch so metric events with all-zero counters stay in the user-visible total and JSON/OTLP exports of the same range agree. - Accept an optional AbortSignal so callers can cancel a long-running export between events and between files.
1 parent f14cdb2 commit addc6b9

4 files changed

Lines changed: 355 additions & 49 deletions

File tree

src/telemetry/export/writers/otlp/records.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export function newCumulativeState(): CumulativeState {
6969
return { anchor: undefined, totals: new Map() };
7070
}
7171

72+
/**
73+
* Resource attributes describe the export tool (forwarder), not the original
74+
* producer. Per-event identity is stamped on each record by
75+
* `eventContextAttributes` so multi-session exports preserve provenance.
76+
*/
7277
export function otlpResource(context: TelemetryContext) {
7378
return {
7479
attributes: keyValues({
@@ -86,6 +91,19 @@ export function otlpResource(context: TelemetryContext) {
8691
};
8792
}
8893

94+
/**
95+
* Per-event identity stamped on every record so multi-session exports stay
96+
* attributable. Spread LAST in attribute maps so caller-supplied properties
97+
* keyed `coder.event.*` cannot override the canonical provenance.
98+
*/
99+
function eventContextAttributes(event: TelemetryEvent): Record<string, string> {
100+
return {
101+
"coder.event.extension_version": event.context.extensionVersion,
102+
"coder.event.session_id": event.context.sessionId,
103+
"coder.event.deployment_url": event.context.deploymentUrl,
104+
};
105+
}
106+
89107
export function otlpScope(version: string) {
90108
return { name: "coder.vscode-coder.telemetry.export", version };
91109
}
@@ -103,6 +121,7 @@ export function logRecord(event: TelemetryEvent): OtlpLogRecord {
103121
...event.properties,
104122
...event.measurements,
105123
...(event.error && exceptionAttributes(event.error)),
124+
...eventContextAttributes(event),
106125
}),
107126
};
108127
}
@@ -125,9 +144,10 @@ export function spanRecord(
125144
startTimeUnixNano: String(startNano),
126145
endTimeUnixNano,
127146
attributes: keyValues({
128-
"coder.event_name": event.eventName,
129147
...event.properties,
130148
...measurements,
149+
"coder.event_name": event.eventName,
150+
...eventContextAttributes(event),
131151
}),
132152
status: spanStatus(event),
133153
...(event.error && {
@@ -184,8 +204,9 @@ export function metricRecords(
184204
eventName: event.eventName,
185205
properties: event.properties,
186206
attributes: keyValues({
187-
"coder.event_name": event.eventName,
188207
...event.properties,
208+
"coder.event_name": event.eventName,
209+
...eventContextAttributes(event),
189210
}),
190211
timeNano,
191212
windowStartNano,

src/telemetry/export/writers/otlp/writer.ts

Lines changed: 164 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { zip } from "fflate";
1+
import { Zip, ZipDeflate } from "fflate";
2+
import { createReadStream, createWriteStream, type WriteStream } from "node:fs";
23
import * as fs from "node:fs/promises";
4+
import * as os from "node:os";
35
import * as path from "node:path";
4-
import { promisify } from "node:util";
56

6-
import { wrapError } from "../../../../error/errorUtils";
7+
import { isAbortError, toError, wrapError } from "../../../../error/errorUtils";
78
import { writeAtomically } from "../../../../util/fs";
89
import { describeMetricEvent } from "../../metrics";
910

@@ -24,49 +25,90 @@ import {
2425

2526
import type { TelemetryContext, TelemetryEvent } from "../../../event";
2627

28+
/** Event totals by signal — a metric event with all records suppressed still counts as one. */
2729
export interface OtlpExportCounts {
2830
readonly logs: number;
2931
readonly traces: number;
3032
readonly metrics: number;
3133
}
3234

35+
export interface OtlpWriteOptions {
36+
readonly signal?: AbortSignal;
37+
readonly onTempCleanupError?: (err: unknown, tempPath: string) => void;
38+
/** Fires on either success or failure path so cleanup errors never mask the export outcome. */
39+
readonly onStagingCleanupError?: (err: unknown, dir: string) => void;
40+
}
41+
3342
interface Channel {
3443
file: EnvelopeFile;
3544
count: number;
3645
}
3746

38-
const zipAsync = promisify(zip);
47+
const READ_HWM_BYTES = 256 * 1024;
3948

4049
/**
4150
* Writes `events` as an OTLP/JSON zip (`logs.json`, `traces.json`,
42-
* `metrics.json`) to `outputPath`.
51+
* `metrics.json`) to `outputPath`. Staging happens in the OS temp dir so
52+
* cloud-sync agents on the user's chosen save location never see the
53+
* intermediate uncompressed envelopes.
4354
*/
4455
export async function writeOtlpZipExport(
4556
outputPath: string,
4657
events: AsyncIterable<TelemetryEvent>,
4758
context: TelemetryContext,
48-
onCleanupError: (err: unknown, tempPath: string) => void,
59+
options: OtlpWriteOptions = {},
4960
): Promise<OtlpExportCounts> {
61+
throwIfAborted(options.signal);
5062
return writeAtomically(
5163
outputPath,
5264
async (zipPath) => {
53-
const stagingDir = await fs.mkdtemp(`${outputPath}.staging-`);
65+
const stagingDir = await fs.mkdtemp(
66+
path.join(os.tmpdir(), "coder-telemetry-otlp-"),
67+
);
68+
let counts: OtlpExportCounts;
5469
try {
55-
const counts = await writeStagedFiles(stagingDir, events, context);
56-
await packZip(zipPath, stagingDir);
57-
return counts;
58-
} finally {
59-
await fs.rm(stagingDir, { recursive: true, force: true });
70+
counts = await writeStagedFiles(
71+
stagingDir,
72+
events,
73+
context,
74+
options.signal,
75+
);
76+
await packZip(zipPath, stagingDir, options.signal);
77+
} catch (err) {
78+
await safeRemove(stagingDir, options.onStagingCleanupError);
79+
throw err;
6080
}
81+
await safeRemove(stagingDir, options.onStagingCleanupError);
82+
return counts;
6183
},
62-
onCleanupError,
84+
options.onTempCleanupError ?? swallowCleanupError,
6385
);
6486
}
6587

88+
function swallowCleanupError(): void {
89+
/* Default: temp-cleanup errors from writeAtomically are silently dropped. */
90+
}
91+
92+
async function safeRemove(
93+
dir: string,
94+
onError?: (err: unknown, dir: string) => void,
95+
): Promise<void> {
96+
try {
97+
await fs.rm(dir, { recursive: true, force: true });
98+
} catch (err) {
99+
try {
100+
onError?.(err, dir);
101+
} catch {
102+
// Swallow callback throws so they don't displace the export error.
103+
}
104+
}
105+
}
106+
66107
async function writeStagedFiles(
67108
dir: string,
68109
events: AsyncIterable<TelemetryEvent>,
69110
context: TelemetryContext,
111+
signal: AbortSignal | undefined,
70112
): Promise<OtlpExportCounts> {
71113
const resource = JSON.stringify(otlpResource(context));
72114
const scope = JSON.stringify(otlpScope(context.extensionVersion));
@@ -76,6 +118,7 @@ async function writeStagedFiles(
76118
let succeeded = false;
77119
try {
78120
for await (const event of events) {
121+
throwIfAborted(signal);
79122
await routeEvent(event, channels, state);
80123
}
81124
succeeded = true;
@@ -146,10 +189,13 @@ async function routeEvent(
146189
channels.metrics,
147190
metricRecords(event, metric, state),
148191
);
192+
channels.metrics.count += 1;
149193
} else if (hasTraceId(event)) {
150194
await appendRecords(channels.traces, [spanRecord(event)]);
195+
channels.traces.count += 1;
151196
} else {
152197
await appendRecords(channels.logs, [logRecord(event)]);
198+
channels.logs.count += 1;
153199
}
154200
} catch (err) {
155201
throw wrapError(
@@ -164,29 +210,118 @@ async function appendRecords(
164210
channel: Channel,
165211
records: Iterable<unknown>,
166212
): Promise<void> {
167-
let wrote = false;
168213
for (const record of records) {
169214
await channel.file.append(record);
170-
wrote = true;
171-
}
172-
if (wrote) {
173-
channel.count += 1;
174215
}
175216
}
176217

177-
async function packZip(outputPath: string, sourceDir: string): Promise<void> {
218+
/** Streams the staged envelopes into a deflate-compressed zip; AbortError is rethrown unwrapped. */
219+
async function packZip(
220+
outputPath: string,
221+
sourceDir: string,
222+
signal: AbortSignal | undefined,
223+
): Promise<void> {
224+
const outStream = createWriteStream(outputPath);
178225
try {
179-
const entries = await Promise.all(
180-
Object.values(ENVELOPES).map(
181-
async (envelope) =>
182-
[
183-
envelope.file,
184-
await fs.readFile(path.join(sourceDir, envelope.file)),
185-
] as const,
186-
),
187-
);
188-
await fs.writeFile(outputPath, await zipAsync(Object.fromEntries(entries)));
226+
await streamEnvelopesIntoZip(outStream, sourceDir, signal);
189227
} catch (err) {
228+
outStream.destroy();
229+
if (isAbortError(err)) {
230+
throw err;
231+
}
190232
throw wrapError("pack OTLP zip", path.basename(outputPath), err);
191233
}
192234
}
235+
236+
/** Bridges fflate's Zip callback onto `outStream`; the pump awaits 'drain' on backpressure. */
237+
function streamEnvelopesIntoZip(
238+
outStream: WriteStream,
239+
sourceDir: string,
240+
signal: AbortSignal | undefined,
241+
): Promise<void> {
242+
return new Promise<void>((resolve, reject) => {
243+
const fail = (err: unknown): void => reject(toError(err));
244+
245+
const waitForDrain = (): Promise<void> =>
246+
outStream.writableNeedDrain
247+
? new Promise<void>((r) => outStream.once("drain", r))
248+
: Promise.resolve();
249+
250+
outStream.on("error", fail);
251+
252+
const zip = new Zip((err, chunk, final) => {
253+
if (err) {
254+
fail(err);
255+
return;
256+
}
257+
if (final) {
258+
// end() waits for in-flight writes before 'finish'; no pendingWrites counter needed.
259+
outStream.end(chunk, () => resolve());
260+
} else {
261+
outStream.write(chunk, (writeErr) => {
262+
if (writeErr) {
263+
fail(writeErr);
264+
}
265+
});
266+
}
267+
});
268+
269+
void pumpEnvelopes(zip, sourceDir, signal, waitForDrain).catch((err) => {
270+
zip.terminate();
271+
fail(err);
272+
});
273+
});
274+
}
275+
276+
async function pumpEnvelopes(
277+
zip: Zip,
278+
sourceDir: string,
279+
signal: AbortSignal | undefined,
280+
waitForDrain: () => Promise<void>,
281+
): Promise<void> {
282+
for (const envelope of Object.values(ENVELOPES)) {
283+
throwIfAborted(signal);
284+
await streamFileIntoZip(
285+
zip,
286+
envelope.file,
287+
path.join(sourceDir, envelope.file),
288+
signal,
289+
waitForDrain,
290+
);
291+
}
292+
zip.end();
293+
}
294+
295+
async function streamFileIntoZip(
296+
zip: Zip,
297+
name: string,
298+
filePath: string,
299+
signal: AbortSignal | undefined,
300+
waitForDrain: () => Promise<void>,
301+
): Promise<void> {
302+
const entry = new ZipDeflate(name);
303+
zip.add(entry);
304+
const readStream = createReadStream(filePath, {
305+
highWaterMark: READ_HWM_BYTES,
306+
});
307+
try {
308+
for await (const chunk of readStream) {
309+
throwIfAborted(signal);
310+
entry.push(chunk as Uint8Array, false);
311+
await waitForDrain();
312+
}
313+
entry.push(new Uint8Array(0), true);
314+
} finally {
315+
readStream.destroy();
316+
}
317+
}
318+
319+
/** Like AbortSignal.throwIfAborted() but coerces non-Error reasons to a named AbortError. */
320+
function throwIfAborted(signal: AbortSignal | undefined): void {
321+
if (signal?.aborted) {
322+
const reason: unknown = signal.reason;
323+
throw reason instanceof Error
324+
? reason
325+
: Object.assign(new Error("Aborted"), { name: "AbortError" });
326+
}
327+
}

0 commit comments

Comments
 (0)