Skip to content

Commit c5ac015

Browse files
committed
feat(telemetry): restructure OTLP export into per-session resource blocks
The writer now opens a new resourceLogs/resourceSpans/resourceMetrics block whenever the producing event's session context or UTC date changes, building the resource from that event's context instead of the exporting session's. This fixes resource attribution for multi-session exports and removes the three coder.event.* provenance attributes from every record. Within a block, metric data points are grouped under one metrics[] entry per (name, unit) as the OTLP proto expects, instead of one single-point metric per event measurement. Cumulative counter totals and anchors reset at block boundaries, mirroring how OTel models a process restart. Zip entries now deflate at level 9; exports run on demand, so CPU cost is irrelevant next to bundle size.
1 parent fdff729 commit c5ac015

17 files changed

Lines changed: 548 additions & 477 deletions

File tree

src/instrumentation/CONVENTIONS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ Coder's own pipeline, so a bare `cache_source` can't collide with a future OTel
115115
- **Measurements** are raw numbers. Don't pre-bucket into string labels: both
116116
export as record attributes, and a query can bucket the raw number at read
117117
time. `result` and `durationMs` are framework-managed and cannot be set.
118+
`durationMs` never reaches OTLP; the export derives span start/end times
119+
from it.
118120
- **Units.** There is no unit field at emit time, so put the unit in the
119121
measurement key as a `_ms` / `_seconds` / `_mbits` suffix, the same way for
120122
every event.

src/instrumentation/EVENTS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,8 @@ context.
8282
On OTLP export the context becomes resource attributes (`service.name:
8383
coder-vscode-extension`, `service.version`, `service.instance.id`, `host.id`,
8484
`host.arch`, `os.type`, `os.version`, `vscode.platform.name`,
85-
`vscode.platform.version`, `coder.deployment.url`) plus per-record provenance
86-
(`coder.event.extension_version`, `coder.event.session_id`,
87-
`coder.event.deployment_url`).
85+
`vscode.platform.version`, `coder.deployment.url`) on the resource block
86+
holding the producing session's records.
8887

8988
## Consuming exports
9089

@@ -97,7 +96,11 @@ date range in one of two formats:
9796
ad-hoc processing.
9897
- **OTLP**: a zip of standard OTLP/JSON envelopes (spans in `traces.json`,
9998
logs in `logs.json`, metric events as data points in `metrics.json`) plus
100-
a `manifest.json` describing the export. Feed these to any OTel-compatible
99+
a `manifest.json` describing the export. Each envelope holds one resource
100+
block per producing session and UTC date, carrying that session's context
101+
as resource attributes; within a block, metric data points are grouped
102+
under one `metrics[]` entry per metric name and unit, and cumulative
103+
counters restart at the block boundary. Feed these to any OTel-compatible
101104
tool that ingests OTLP/JSON, such as an OpenTelemetry Collector pipeline or
102105
your observability backend's import tooling.
103106

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@ import { createWriteStream } from "node:fs";
22

33
import { wrapError } from "../../../../error/errorUtils";
44

5-
/** `append` is not re-entrant. */
5+
/** `openBlock` and `append` are not re-entrant. */
66
export interface EnvelopeFile {
7+
/** Opens a new block, closing the previous one with the block suffix. */
8+
openBlock(prefix: string): Promise<void>;
9+
/** Appends one record to the open block; rejects if no block is open. */
710
append(value: unknown): Promise<void>;
811
close(): Promise<void>;
912
}
1013

11-
/** Streams `<prefix>v1,v2,...<suffix>` JSON into `filePath`. */
14+
/**
15+
* Streams `<filePrefix>block,block,...<fileSuffix>` JSON into `filePath`,
16+
* where each block is `<prefix>v1,v2,...<blockSuffix>`.
17+
*/
1218
export async function openEnvelopeFile(
1319
filePath: string,
14-
prefix: string,
15-
suffix: string,
20+
filePrefix: string,
21+
blockSuffix: string,
22+
fileSuffix: string,
1623
): Promise<EnvelopeFile> {
1724
const stream = createWriteStream(filePath, { encoding: "utf8" });
1825
// Open failures (ENOENT/EACCES) surface as 'error' events, not write
@@ -42,15 +49,28 @@ export async function openEnvelopeFile(
4249
awaitOp((cb) => stream.write(chunk, "utf8", cb));
4350

4451
try {
45-
await writeChunk(prefix);
52+
await writeChunk(filePrefix);
4653
} catch (err) {
4754
stream.destroy();
4855
throw wrapError("write", filePath, err);
4956
}
57+
let blockOpen = false;
5058
let written = 0;
5159
let closed = false;
5260
return {
61+
async openBlock(prefix) {
62+
try {
63+
await writeChunk((blockOpen ? blockSuffix + "," : "") + prefix);
64+
} catch (err) {
65+
throw wrapError("write", filePath, err);
66+
}
67+
blockOpen = true;
68+
written = 0;
69+
},
5370
async append(value) {
71+
if (!blockOpen) {
72+
throw new Error(`No open block to append to in ${filePath}`);
73+
}
5474
try {
5575
await writeChunk((written === 0 ? "" : ",") + JSON.stringify(value));
5676
} catch (err) {
@@ -64,7 +84,7 @@ export async function openEnvelopeFile(
6484
}
6585
closed = true;
6686
try {
67-
await writeChunk(suffix);
87+
await writeChunk((blockOpen ? blockSuffix : "") + fileSuffix);
6888
await awaitOp((cb) => stream.end(cb));
6989
} catch (err) {
7090
// destroy() never throws synchronously, so it can't mask the
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { OtlpGaugePoint, OtlpMetric, OtlpSumPoint } from "./types";
2+
3+
interface BufferedMetricSeries {
4+
/** First record seen for the series; carries name/description/unit and sum metadata. */
5+
readonly template: OtlpMetric;
6+
readonly gaugePoints: OtlpGaugePoint[];
7+
readonly sumPoints: OtlpSumPoint[];
8+
}
9+
10+
/**
11+
* Groups single-point metric records into one `metrics[]` entry per
12+
* (name, unit, kind), as the OTLP metrics proto expects. The writer drains
13+
* the buffer at block boundaries and at a point cap, bounding memory.
14+
*/
15+
export class MetricBlockBuffer {
16+
readonly #series = new Map<string, BufferedMetricSeries>();
17+
#points = 0;
18+
19+
/** Buffered data point count across all series. */
20+
get points(): number {
21+
return this.#points;
22+
}
23+
24+
add(records: readonly OtlpMetric[]): void {
25+
for (const record of records) {
26+
const kind = record.gauge !== undefined ? "gauge" : "sum";
27+
const key = `${record.name}\x00${record.unit}\x00${kind}`;
28+
let series = this.#series.get(key);
29+
if (series === undefined) {
30+
series = { template: record, gaugePoints: [], sumPoints: [] };
31+
this.#series.set(key, series);
32+
}
33+
series.gaugePoints.push(...(record.gauge?.dataPoints ?? []));
34+
series.sumPoints.push(...(record.sum?.dataPoints ?? []));
35+
this.#points +=
36+
(record.gauge?.dataPoints.length ?? 0) +
37+
(record.sum?.dataPoints.length ?? 0);
38+
}
39+
}
40+
41+
/** Returns the grouped metrics in first-seen order and clears the buffer. */
42+
drain(): OtlpMetric[] {
43+
const drained = [...this.#series.values()].map(
44+
({ template, gaugePoints, sumPoints }): OtlpMetric =>
45+
template.sum !== undefined
46+
? { ...template, sum: { ...template.sum, dataPoints: sumPoints } }
47+
: { ...template, gauge: { dataPoints: gaugePoints } },
48+
);
49+
this.#series.clear();
50+
this.#points = 0;
51+
return drained;
52+
}
53+
}

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

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export const ENVELOPES = {
4545
} as const satisfies Record<Signal, EnvelopeSpec>;
4646

4747
const OTLP_SCHEMA_URL = "https://opentelemetry.io/schemas/1.24.0";
48-
export const ENVELOPE_SUFFIX = "]}]}]}\n";
48+
/** Closes one resource block: records array, scope wrapper, resource wrapper. */
49+
export const RESOURCE_BLOCK_SUFFIX = "]}]}";
50+
/** Closes the top-level resource list opened by `envelopeFilePrefix`. */
51+
export const ENVELOPE_FILE_SUFFIX = "]}\n";
4952

5053
const AGGREGATION_TEMPORALITY_CUMULATIVE = 2;
5154
// OTLP proto SpanKind reserves 0 for UNSPECIFIED; @opentelemetry/api values
@@ -55,12 +58,18 @@ const OTLP_SPAN_KIND_OFFSET = 1;
5558
// OtlpStatus.code is a wire-format int, so compare against a numeric alias.
5659
const OTLP_STATUS_ERROR: number = SpanStatusCode.ERROR;
5760

58-
export function envelopePrefix(
61+
/** Opens the envelope's top-level resource list (`resourceLogs` etc.). */
62+
export function envelopeFilePrefix(envelope: EnvelopeSpec): string {
63+
return `{"${envelope.resourceKey}":[`;
64+
}
65+
66+
/** Opens one resource block: resource and scope wrappers up to the records array. */
67+
export function resourceBlockPrefix(
5968
envelope: EnvelopeSpec,
6069
resource: string,
6170
scope: string,
6271
): string {
63-
return `{"${envelope.resourceKey}":[{"resource":${resource},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.scopeKey}":[{"scope":${scope},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.recordsKey}":[`;
72+
return `{"resource":${resource},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.scopeKey}":[{"scope":${scope},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.recordsKey}":[`;
6473
}
6574

6675
export interface CumulativeState {
@@ -73,9 +82,10 @@ export function newCumulativeState(): CumulativeState {
7382
}
7483

7584
/**
76-
* Resource attributes describe the export tool (forwarder), not the original
77-
* producer. Per-event identity is stamped on each record by
78-
* `eventContextAttributes` so multi-session exports preserve provenance.
85+
* Resource attributes identifying the session that produced a resource
86+
* block's records. The writer builds one per block from the producing
87+
* event's context, so multi-session exports attribute every record to its
88+
* own session.
7989
*/
8090
export function otlpResource(context: TelemetryContext) {
8191
return {
@@ -94,19 +104,6 @@ export function otlpResource(context: TelemetryContext) {
94104
};
95105
}
96106

97-
/**
98-
* Per-event identity stamped on every record so multi-session exports stay
99-
* attributable. Spread LAST in attribute maps so caller-supplied properties
100-
* keyed `coder.event.*` cannot override the canonical provenance.
101-
*/
102-
function eventContextAttributes(event: TelemetryEvent): Record<string, string> {
103-
return {
104-
"coder.event.extension_version": event.context.extensionVersion,
105-
"coder.event.session_id": event.context.sessionId,
106-
"coder.event.deployment_url": event.context.deploymentUrl,
107-
};
108-
}
109-
110107
export function otlpScope(version: string) {
111108
return { name: "coder.vscode-coder.telemetry.export", version };
112109
}
@@ -130,7 +127,6 @@ export function logRecord(event: TelemetryEvent): OtlpLogRecord {
130127
...event.properties,
131128
...event.measurements,
132129
...(event.error && exceptionAttributes(event.error)),
133-
...eventContextAttributes(event),
134130
}),
135131
...(event.traceId !== undefined && { traceId: event.traceId }),
136132
...(event.parentEventId !== undefined && { spanId: event.parentEventId }),
@@ -149,7 +145,6 @@ export function spanRecord(
149145
...event.properties,
150146
...measurements,
151147
"coder.event_name": event.eventName,
152-
...eventContextAttributes(event),
153148
};
154149
// OTel wants every error span to carry a low-cardinality error.type, so
155150
// backfill the exception type or code, falling back to OTel's _OTHER sentinel.
@@ -231,7 +226,6 @@ export function metricRecords(
231226
attributes: keyValues({
232227
...event.properties,
233228
"coder.event_name": event.eventName,
234-
...eventContextAttributes(event),
235229
}),
236230
timeNano,
237231
windowStartNano,

0 commit comments

Comments
 (0)