Skip to content

Commit d8aa467

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 9951691 commit d8aa467

4 files changed

Lines changed: 263 additions & 30 deletions

File tree

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

Lines changed: 20 additions & 0 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,18 @@ export function otlpResource(context: TelemetryContext) {
8691
};
8792
}
8893

94+
/**
95+
* Per-event identity stamped on every record so multi-session exports stay
96+
* attributable even when the resource snapshot is from the export tool.
97+
*/
98+
function eventContextAttributes(event: TelemetryEvent): Record<string, string> {
99+
return {
100+
"coder.event.extension_version": event.context.extensionVersion,
101+
"coder.event.session_id": event.context.sessionId,
102+
"coder.event.deployment_url": event.context.deploymentUrl,
103+
};
104+
}
105+
89106
export function otlpScope(version: string) {
90107
return { name: "coder.vscode-coder.telemetry.export", version };
91108
}
@@ -100,6 +117,7 @@ export function logRecord(event: TelemetryEvent): OtlpLogRecord {
100117
severityText: errored ? "ERROR" : "INFO",
101118
body: { stringValue: event.eventName },
102119
attributes: keyValues({
120+
...eventContextAttributes(event),
103121
...event.properties,
104122
...event.measurements,
105123
...(event.error && exceptionAttributes(event.error)),
@@ -126,6 +144,7 @@ export function spanRecord(
126144
endTimeUnixNano,
127145
attributes: keyValues({
128146
"coder.event_name": event.eventName,
147+
...eventContextAttributes(event),
129148
...event.properties,
130149
...measurements,
131150
}),
@@ -185,6 +204,7 @@ export function metricRecords(
185204
properties: event.properties,
186205
attributes: keyValues({
187206
"coder.event_name": event.eventName,
207+
...eventContextAttributes(event),
188208
...event.properties,
189209
}),
190210
timeNano,

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

Lines changed: 137 additions & 28 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 } 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 { toError, wrapError } from "../../../../error/errorUtils";
78
import { writeAtomically } from "../../../../util/fs";
89
import { describeMetricEvent } from "../../metrics";
910

@@ -30,43 +31,75 @@ export interface OtlpExportCounts {
3031
readonly metrics: number;
3132
}
3233

34+
export interface OtlpWriteOptions {
35+
readonly signal?: AbortSignal;
36+
/**
37+
* Invoked when removing the staging directory fails after the export
38+
* itself succeeded. Letting the cleanup throw would mask success.
39+
*/
40+
readonly onStagingCleanupError?: (err: unknown, dir: string) => void;
41+
}
42+
3343
interface Channel {
3444
file: EnvelopeFile;
3545
count: number;
3646
}
3747

38-
const zipAsync = promisify(zip);
39-
4048
/**
4149
* Writes `events` as an OTLP/JSON zip (`logs.json`, `traces.json`,
42-
* `metrics.json`) to `outputPath`.
50+
* `metrics.json`) to `outputPath`. Staging happens in the OS temp dir so
51+
* cloud-sync agents on the user's chosen save location never see the
52+
* intermediate uncompressed envelopes.
4353
*/
4454
export async function writeOtlpZipExport(
4555
outputPath: string,
4656
events: AsyncIterable<TelemetryEvent>,
4757
context: TelemetryContext,
4858
onCleanupError: (err: unknown, tempPath: string) => void,
59+
options: OtlpWriteOptions = {},
4960
): Promise<OtlpExportCounts> {
5061
return writeAtomically(
5162
outputPath,
5263
async (zipPath) => {
53-
const stagingDir = await fs.mkdtemp(`${outputPath}.staging-`);
64+
const stagingDir = await fs.mkdtemp(
65+
path.join(os.tmpdir(), "coder-telemetry-otlp-"),
66+
);
67+
let counts: OtlpExportCounts;
5468
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 });
69+
counts = await writeStagedFiles(
70+
stagingDir,
71+
events,
72+
context,
73+
options.signal,
74+
);
75+
await packZip(zipPath, stagingDir, options.signal);
76+
} catch (err) {
77+
await safeRemove(stagingDir, options.onStagingCleanupError);
78+
throw err;
6079
}
80+
await safeRemove(stagingDir, options.onStagingCleanupError);
81+
return counts;
6182
},
6283
onCleanupError,
6384
);
6485
}
6586

87+
async function safeRemove(
88+
dir: string,
89+
onError?: (err: unknown, dir: string) => void,
90+
): Promise<void> {
91+
try {
92+
await fs.rm(dir, { recursive: true, force: true });
93+
} catch (err) {
94+
onError?.(err, dir);
95+
}
96+
}
97+
6698
async function writeStagedFiles(
6799
dir: string,
68100
events: AsyncIterable<TelemetryEvent>,
69101
context: TelemetryContext,
102+
signal: AbortSignal | undefined,
70103
): Promise<OtlpExportCounts> {
71104
const resource = JSON.stringify(otlpResource(context));
72105
const scope = JSON.stringify(otlpScope(context.extensionVersion));
@@ -76,6 +109,7 @@ async function writeStagedFiles(
76109
let succeeded = false;
77110
try {
78111
for await (const event of events) {
112+
throwIfAborted(signal);
79113
await routeEvent(event, channels, state);
80114
}
81115
succeeded = true;
@@ -142,14 +176,20 @@ async function routeEvent(
142176
try {
143177
const metric = describeMetricEvent(event);
144178
if (metric) {
179+
// Count metric events even when every measurement is suppressed (e.g.
180+
// http.requests window with all counters at zero); otherwise JSON and
181+
// OTLP exports of the same range report different event totals.
145182
await appendRecords(
146183
channels.metrics,
147184
metricRecords(event, metric, state),
148185
);
186+
channels.metrics.count += 1;
149187
} else if (hasTraceId(event)) {
150188
await appendRecords(channels.traces, [spanRecord(event)]);
189+
channels.traces.count += 1;
151190
} else {
152191
await appendRecords(channels.logs, [logRecord(event)]);
192+
channels.logs.count += 1;
153193
}
154194
} catch (err) {
155195
throw wrapError(
@@ -164,29 +204,98 @@ async function appendRecords(
164204
channel: Channel,
165205
records: Iterable<unknown>,
166206
): Promise<void> {
167-
let wrote = false;
168207
for (const record of records) {
169208
await channel.file.append(record);
170-
wrote = true;
171-
}
172-
if (wrote) {
173-
channel.count += 1;
174209
}
175210
}
176211

177-
async function packZip(outputPath: string, sourceDir: string): Promise<void> {
212+
/**
213+
* Streams each staged envelope file into a deflate-compressed zip without
214+
* holding the file contents in memory. Honors `signal` between files; the
215+
* inner read pump only checks before pulling the next chunk.
216+
*/
217+
async function packZip(
218+
outputPath: string,
219+
sourceDir: string,
220+
signal: AbortSignal | undefined,
221+
): Promise<void> {
222+
const outStream = createWriteStream(outputPath);
178223
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)));
224+
await new Promise<void>((resolve, reject) => {
225+
let zipEnded = false;
226+
let pendingWrites = 0;
227+
const onError = (err: unknown) => reject(toError(err));
228+
229+
outStream.once("error", onError);
230+
231+
const zip = new Zip((err, chunk, final) => {
232+
if (err) {
233+
onError(err);
234+
return;
235+
}
236+
pendingWrites += 1;
237+
outStream.write(chunk, (writeErr) => {
238+
pendingWrites -= 1;
239+
if (writeErr) {
240+
onError(writeErr);
241+
return;
242+
}
243+
if (final && zipEnded && pendingWrites === 0) {
244+
outStream.end(() => resolve());
245+
}
246+
});
247+
});
248+
249+
void (async () => {
250+
try {
251+
for (const envelope of Object.values(ENVELOPES)) {
252+
throwIfAborted(signal);
253+
await streamFileIntoZip(
254+
zip,
255+
envelope.file,
256+
path.join(sourceDir, envelope.file),
257+
signal,
258+
);
259+
}
260+
zip.end();
261+
zipEnded = true;
262+
} catch (err) {
263+
zip.terminate();
264+
reject(toError(err));
265+
}
266+
})();
267+
});
189268
} catch (err) {
269+
outStream.destroy();
190270
throw wrapError("pack OTLP zip", path.basename(outputPath), err);
191271
}
192272
}
273+
274+
async function streamFileIntoZip(
275+
zip: Zip,
276+
name: string,
277+
filePath: string,
278+
signal: AbortSignal | undefined,
279+
): Promise<void> {
280+
const entry = new ZipDeflate(name);
281+
zip.add(entry);
282+
const readStream = createReadStream(filePath);
283+
try {
284+
for await (const chunk of readStream) {
285+
throwIfAborted(signal);
286+
entry.push(chunk as Uint8Array, false);
287+
}
288+
entry.push(new Uint8Array(0), true);
289+
} finally {
290+
readStream.destroy();
291+
}
292+
}
293+
294+
function throwIfAborted(signal: AbortSignal | undefined): void {
295+
if (signal?.aborted) {
296+
const reason: unknown = signal.reason;
297+
throw reason instanceof Error
298+
? reason
299+
: Object.assign(new Error("Aborted"), { name: "AbortError" });
300+
}
301+
}

test/unit/telemetry/export/writers/otlp/records.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import { TRACE_ID, attrs } from "./helpers";
2020

2121
const makeEvent = createTelemetryEventFactory();
2222

23+
/** Per-event identity that every record carries, derived from the factory defaults. */
24+
const EVENT_CONTEXT_ATTRS = {
25+
"coder.event.extension_version": "1.14.5",
26+
"coder.event.session_id": "session-id",
27+
"coder.event.deployment_url": "https://coder.example.com",
28+
} as const;
29+
2330
const makeSpanEvent = (overrides: Parameters<typeof makeEvent>[0] = {}) => ({
2431
...makeEvent({ traceId: TRACE_ID, ...overrides }),
2532
traceId: TRACE_ID,
@@ -80,7 +87,11 @@ describe("logRecord", () => {
8087
body: { stringValue: "log.info" },
8188
});
8289
expect(record.timeUnixNano).toBe(record.observedTimeUnixNano);
83-
expect(attrs(record.attributes)).toEqual({ source: "unit", count: 3 });
90+
expect(attrs(record.attributes)).toEqual({
91+
...EVENT_CONTEXT_ATTRS,
92+
source: "unit",
93+
count: 3,
94+
});
8495
});
8596

8697
it("emits ERROR records and omits optional exception fields when unset", () => {
@@ -97,7 +108,29 @@ describe("logRecord", () => {
97108
"exception.type": "RangeError",
98109
"exception.code": "E_RANGE",
99110
});
100-
expect(attrs(minimal.attributes)).toEqual({ "exception.message": "boom" });
111+
expect(attrs(minimal.attributes)).toEqual({
112+
...EVENT_CONTEXT_ATTRS,
113+
"exception.message": "boom",
114+
});
115+
});
116+
117+
it("stamps each event's own context onto its record", () => {
118+
const base = makeEvent();
119+
const record = logRecord({
120+
...base,
121+
context: {
122+
...base.context,
123+
extensionVersion: "0.9.0",
124+
sessionId: "older-session",
125+
deploymentUrl: "https://prev.coder.example.com",
126+
},
127+
});
128+
129+
expect(attrs(record.attributes)).toMatchObject({
130+
"coder.event.extension_version": "0.9.0",
131+
"coder.event.session_id": "older-session",
132+
"coder.event.deployment_url": "https://prev.coder.example.com",
133+
});
101134
});
102135
});
103136

@@ -124,6 +157,7 @@ describe("spanRecord", () => {
124157
);
125158
expect(attrs(span.attributes)).toEqual({
126159
"coder.event_name": "remote.setup.workspace_ready",
160+
...EVENT_CONTEXT_ATTRS,
127161
result: "success",
128162
route: "/api",
129163
retries: 2,

0 commit comments

Comments
 (0)