Skip to content

Commit 7ce5f22

Browse files
committed
fix(telemetry): make OTLP export ingestable by Prom-family backends
Wire-format fixes: - Switch http.requests count_* sums from DELTA to CUMULATIVE temporality (Prom/Mimir/Grafana Cloud reject the delta+sum combination). - Maintain per-series running totals across the export, anchored at the first observed window's startTimeUnixNano. - Suppress zero-cumulative counters so routes that never errored don't ship empty data points. Semantic-convention compliance: - Use OTel-standard `service.instance.id` (was `coder.session.id`) and `host.id` (was `coder.machine.id`) for portability with OTel-aware backends. The previous vendor keys were pure aliases. - Add `schemaUrl` to each ResourceLogs/ResourceSpans/ResourceMetrics container and each scope container. - Stamp scope.version with the extension version.
1 parent 71bcce7 commit 7ce5f22

2 files changed

Lines changed: 170 additions & 59 deletions

File tree

src/telemetry/export/writers/otlp.ts

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export interface OtlpExportCounts {
2929
// OTLP proto SpanKind reserves 0 for UNSPECIFIED, so api values shift by 1
3030
// on the wire. AGGREGATION_TEMPORALITY has no enum in @opentelemetry/api.
3131
const otlpSpanKind = (kind: SpanKind): number => kind + 1;
32-
const AGGREGATION_TEMPORALITY_DELTA = 1;
32+
const AGGREGATION_TEMPORALITY_CUMULATIVE = 2;
33+
const OTLP_SCHEMA_URL = "https://opentelemetry.io/schemas/1.24.0";
3334
const zipAsync = promisify(zip);
3435

3536
/**
@@ -60,6 +61,14 @@ export async function writeOtlpZipExport(
6061
});
6162
}
6263

64+
/** Per-export state threaded through the metric mapper for cumulative sums. */
65+
interface ExportState {
66+
readonly cumulative: {
67+
startTimeUnixNano: string | undefined;
68+
readonly totals: Map<string, bigint>;
69+
};
70+
}
71+
6372
// Per-signal layout driving file names, envelope JSON keys, routing, and counts.
6473
const ENVELOPES = [
6574
{
@@ -69,7 +78,7 @@ const ENVELOPES = [
6978
resourceKey: "resourceLogs",
7079
scopeKey: "scopeLogs",
7180
recordsKey: "logRecords",
72-
toRecords: (e: TelemetryEvent) => [toOtlpLogRecord(e)],
81+
toRecords: (e: TelemetryEvent, _state: ExportState) => [toOtlpLogRecord(e)],
7382
},
7483
{
7584
signal: "trace",
@@ -78,7 +87,7 @@ const ENVELOPES = [
7887
resourceKey: "resourceSpans",
7988
scopeKey: "scopeSpans",
8089
recordsKey: "spans",
81-
toRecords: (e: TelemetryEvent) => [toOtlpSpan(e)],
90+
toRecords: (e: TelemetryEvent, _state: ExportState) => [toOtlpSpan(e)],
8291
},
8392
{
8493
signal: "metric",
@@ -96,7 +105,7 @@ const ENVELOPES = [
96105
resourceKey: string;
97106
scopeKey: string;
98107
recordsKey: string;
99-
toRecords: (event: TelemetryEvent) => readonly unknown[];
108+
toRecords: (event: TelemetryEvent, state: ExportState) => readonly unknown[];
100109
}>;
101110

102111
async function writeOtlpJsonFiles(
@@ -105,17 +114,20 @@ async function writeOtlpJsonFiles(
105114
context: TelemetryContext,
106115
): Promise<OtlpExportCounts> {
107116
const resource = JSON.stringify(toOtlpResource(context));
108-
const scope = JSON.stringify(toOtlpScope());
117+
const scope = JSON.stringify(toOtlpScope(context.extensionVersion));
109118
const entries = await Promise.all(
110119
ENVELOPES.map(async (envelope) => ({
111120
...envelope,
112121
writer: await openEnvelope(
113122
path.join(dir, envelope.file),
114-
`{"${envelope.resourceKey}":[{"resource":${resource},"${envelope.scopeKey}":[{"scope":${scope},"${envelope.recordsKey}":[`,
123+
`{"${envelope.resourceKey}":[{"resource":${resource},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.scopeKey}":[{"scope":${scope},"schemaUrl":"${OTLP_SCHEMA_URL}","${envelope.recordsKey}":[`,
115124
"]}]}]}\n",
116125
),
117126
})),
118127
);
128+
const state: ExportState = {
129+
cumulative: { startTimeUnixNano: undefined, totals: new Map() },
130+
};
119131
const counts: Record<keyof OtlpExportCounts, number> = {
120132
logs: 0,
121133
traces: 0,
@@ -130,7 +142,7 @@ async function writeOtlpJsonFiles(
130142
continue;
131143
}
132144
counts[entry.counter] += 1;
133-
for (const record of entry.toRecords(event)) {
145+
for (const record of entry.toRecords(event, state)) {
134146
await entry.writer.write(record);
135147
}
136148
}
@@ -203,11 +215,11 @@ function toOtlpResource(context: TelemetryContext): JsonObject {
203215
attributes: keyValues({
204216
"service.name": "coder-vscode-extension",
205217
"service.version": context.extensionVersion,
206-
"coder.machine.id": context.machineId,
207-
"coder.session.id": context.sessionId,
218+
"service.instance.id": context.sessionId,
219+
"host.id": context.machineId,
220+
"host.arch": context.hostArch,
208221
"os.type": context.osType,
209222
"os.version": context.osVersion,
210-
"host.arch": context.hostArch,
211223
"vscode.platform.name": context.platformName,
212224
"vscode.platform.version": context.platformVersion,
213225
"coder.deployment.url": context.deploymentUrl,
@@ -216,8 +228,8 @@ function toOtlpResource(context: TelemetryContext): JsonObject {
216228
}
217229

218230
/** OTLP `InstrumentationScope` shared by every record. */
219-
function toOtlpScope(): JsonObject {
220-
return { name: "coder.vscode-coder.telemetry.export" };
231+
function toOtlpScope(version: string): JsonObject {
232+
return { name: "coder.vscode-coder.telemetry.export", version };
221233
}
222234

223235
function toOtlpLogRecord(event: TelemetryEvent): JsonObject {
@@ -285,11 +297,14 @@ function spanStatus(event: TelemetryEvent): JsonObject {
285297
}
286298

287299
/** A metric event yields one record per measurement (http.requests fans out). */
288-
function toOtlpMetricRecords(event: TelemetryEvent): JsonObject[] {
300+
function toOtlpMetricRecords(
301+
event: TelemetryEvent,
302+
state: ExportState,
303+
): JsonObject[] {
289304
const timeUnixNano = toUnixNano(event.timestamp);
290305
const attributes = metricAttributes(event);
291306
if (event.eventName === "http.requests") {
292-
return toHttpRequestMetrics(event, timeUnixNano, attributes);
307+
return toHttpRequestMetrics(event, timeUnixNano, attributes, state);
293308
}
294309
return toGaugeMetrics(
295310
event,
@@ -303,6 +318,7 @@ function toHttpRequestMetrics(
303318
event: TelemetryEvent,
304319
timeUnixNano: string,
305320
attributes: JsonObject[],
321+
state: ExportState,
306322
): JsonObject[] {
307323
const counts: Array<[string, number]> = [];
308324
const gauges: Array<[string, number]> = [];
@@ -316,38 +332,64 @@ function toHttpRequestMetrics(
316332
gauges.push([name, value]);
317333
}
318334
}
319-
const startTimeUnixNano = String(
335+
const windowStartUnixNano = String(
320336
BigInt(timeUnixNano) - nanosFromSeconds(windowSeconds ?? 0),
321337
);
322-
323-
return [
324-
...counts.map(([name, value]) => ({
338+
// Cumulative series anchor: first observed window's start, stable across
339+
// every subsequent http.requests data point in this export.
340+
state.cumulative.startTimeUnixNano ??= windowStartUnixNano;
341+
const cumulativeStart = state.cumulative.startTimeUnixNano;
342+
343+
const sumRecords: JsonObject[] = [];
344+
for (const [name, value] of counts) {
345+
const seriesKey = `${name}|${stableSeriesKey(event.properties)}`;
346+
const total =
347+
(state.cumulative.totals.get(seriesKey) ?? 0n) +
348+
BigInt(Math.trunc(value));
349+
state.cumulative.totals.set(seriesKey, total);
350+
// Suppress unchanged-from-zero counters to save bytes; receivers
351+
// interpret an absent series as "no events yet".
352+
if (total === 0n) {
353+
continue;
354+
}
355+
sumRecords.push({
325356
name: `${event.eventName}.${name}`,
326357
description: event.eventName,
327358
unit: "{request}",
328359
sum: {
329-
aggregationTemporality: AGGREGATION_TEMPORALITY_DELTA,
360+
aggregationTemporality: AGGREGATION_TEMPORALITY_CUMULATIVE,
330361
isMonotonic: true,
331362
dataPoints: [
332363
{
333364
attributes,
334-
startTimeUnixNano,
365+
startTimeUnixNano: cumulativeStart,
335366
timeUnixNano,
336-
asInt: String(Math.trunc(value)),
367+
asInt: String(total),
337368
},
338369
],
339370
},
340-
})),
371+
});
372+
}
373+
374+
return [
375+
...sumRecords,
341376
...toGaugeMetrics(
342377
event,
343378
gauges,
344379
attributes,
345380
timeUnixNano,
346-
startTimeUnixNano,
381+
windowStartUnixNano,
347382
),
348383
];
349384
}
350385

386+
function stableSeriesKey(properties: Readonly<Record<string, string>>): string {
387+
return Object.entries(properties)
388+
.sort(([a], [b]) => a.localeCompare(b))
389+
.map(([k, v]) => `${k}=${v}`)
390+
.join("|");
391+
}
392+
351393
function toGaugeMetrics(
352394
event: TelemetryEvent,
353395
measurements: Array<[string, number]>,

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

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,19 @@ interface TracesFile {
5252
resourceSpans: [{ scopeSpans: [{ spans: RawRecord[] }] }];
5353
}
5454
interface MetricsFile {
55-
resourceMetrics: [{ scopeMetrics: [{ metrics: RawRecord[] }] }];
55+
resourceMetrics: [
56+
{
57+
resource: { attributes: unknown };
58+
scopeMetrics: [{ scope: RawRecord; metrics: RawRecord[] }];
59+
},
60+
];
5661
}
5762

5863
interface Captured {
5964
counts: Awaited<ReturnType<typeof writeOtlpZipExport>>;
6065
logResource: { attributes: unknown };
66+
metricsResource: { attributes: unknown };
67+
logsScope: RawRecord;
6168
logs: RawRecord[];
6269
spans: RawRecord[];
6370
metrics: RawRecord[];
@@ -77,27 +84,46 @@ async function capture(events: readonly TelemetryEvent[]): Promise<Captured> {
7784
return {
7885
counts,
7986
logResource: logs.resource,
87+
metricsResource: metrics.resource,
88+
logsScope: (logs.scopeLogs[0] as unknown as { scope: RawRecord }).scope,
8089
logs: logs.scopeLogs[0].logRecords,
8190
spans: traces.scopeSpans[0].spans,
8291
metrics: metrics.scopeMetrics[0].metrics,
8392
};
8493
}
8594

8695
describe("writeOtlpZipExport: resource", () => {
87-
it("emits service, OS, host, vscode, and coder attributes from the context", async () => {
88-
const { logResource } = await capture([makeEvent()]);
89-
90-
expect(attrs(logResource.attributes)).toEqual({
91-
"service.name": "coder-vscode-extension",
92-
"service.version": "1.14.5",
93-
"coder.machine.id": "machine-id",
94-
"coder.session.id": "session-id",
95-
"os.type": "linux",
96-
"os.version": "6.0.0",
97-
"host.arch": "x64",
98-
"vscode.platform.name": "Visual Studio Code",
99-
"vscode.platform.version": "1.106.0",
100-
"coder.deployment.url": "https://coder.example.com",
96+
const EXPECTED_RESOURCE = {
97+
"service.name": "coder-vscode-extension",
98+
"service.version": "1.14.5",
99+
"service.instance.id": "session-id",
100+
"host.id": "machine-id",
101+
"host.arch": "x64",
102+
"os.type": "linux",
103+
"os.version": "6.0.0",
104+
"vscode.platform.name": "Visual Studio Code",
105+
"vscode.platform.version": "1.106.0",
106+
"coder.deployment.url": "https://coder.example.com",
107+
};
108+
109+
it("uses OTel-standard semconv keys on the resource", async () => {
110+
const { logResource, metricsResource } = await capture([
111+
makeEvent(),
112+
makeEvent({
113+
eventName: "ssh.network.sampled",
114+
measurements: { latencyMs: 1 },
115+
}),
116+
]);
117+
118+
expect(attrs(logResource.attributes)).toEqual(EXPECTED_RESOURCE);
119+
expect(attrs(metricsResource.attributes)).toEqual(EXPECTED_RESOURCE);
120+
});
121+
122+
it("stamps each scope with the extension version", async () => {
123+
const { logsScope } = await capture([makeEvent()]);
124+
expect(logsScope).toMatchObject({
125+
name: "coder.vscode-coder.telemetry.export",
126+
version: "1.14.5",
101127
});
102128
});
103129
});
@@ -237,40 +263,83 @@ describe("writeOtlpZipExport: metrics", () => {
237263
});
238264
});
239265

240-
it("splits http.requests into monotonic count sums and gauges sharing the window", async () => {
266+
it("emits http.requests counts as cumulative monotonic sums with a stable start time", async () => {
267+
const { metrics } = await capture([
268+
makeEvent({
269+
eventName: "http.requests",
270+
properties: { method: "GET", route: "/a" },
271+
timestamp: "2026-05-04T12:01:00.000Z",
272+
measurements: { window_seconds: 60, count_2xx: 2 },
273+
}),
274+
makeEvent({
275+
eventName: "http.requests",
276+
properties: { method: "GET", route: "/a" },
277+
timestamp: "2026-05-04T12:02:00.000Z",
278+
measurements: { window_seconds: 60, count_2xx: 3 },
279+
}),
280+
]);
281+
const counts = metrics.map(
282+
(m) =>
283+
m.sum as {
284+
aggregationTemporality: number;
285+
isMonotonic: boolean;
286+
dataPoints: [
287+
{
288+
asInt: string;
289+
startTimeUnixNano: string;
290+
timeUnixNano: string;
291+
},
292+
];
293+
},
294+
);
295+
296+
expect(counts.map((c) => c.dataPoints[0].asInt)).toEqual(["2", "5"]);
297+
expect(counts.every((c) => c.aggregationTemporality === 2)).toBe(true);
298+
expect(counts.every((c) => c.isMonotonic)).toBe(true);
299+
// startTimeUnixNano is set once on the first event and stays fixed.
300+
expect(counts[1].dataPoints[0].startTimeUnixNano).toBe(
301+
counts[0].dataPoints[0].startTimeUnixNano,
302+
);
303+
});
304+
305+
it("keeps gauges windowed alongside the cumulative count sums", async () => {
241306
const { metrics } = await capture([
242307
makeEvent({
243308
eventName: "http.requests",
244309
measurements: { window_seconds: 60, count_2xx: 2, p95_duration_ms: 42 },
245310
}),
246311
]);
247-
const count = metrics[0].sum as {
248-
aggregationTemporality: number;
249-
isMonotonic: boolean;
250-
dataPoints: [
251-
{ asInt: string; startTimeUnixNano: string; timeUnixNano: string },
252-
];
253-
};
254-
const p95Point = (
255-
metrics[1].gauge as { dataPoints: [{ startTimeUnixNano: string }] }
256-
).dataPoints[0];
257312

258313
expect(metrics.map((m) => [m.name, m.unit])).toEqual([
259314
["http.requests.count_2xx", "{request}"],
260315
["http.requests.p95_duration_ms", "ms"],
261316
]);
262-
expect(count).toMatchObject({
263-
aggregationTemporality: 1,
264-
isMonotonic: true,
265-
});
266-
expect(count.dataPoints[0].asInt).toBe("2");
317+
const p95Point = (
318+
metrics[1].gauge as {
319+
dataPoints: [{ startTimeUnixNano: string; timeUnixNano: string }];
320+
}
321+
).dataPoints[0];
267322
expect(
268-
BigInt(count.dataPoints[0].timeUnixNano) -
269-
BigInt(count.dataPoints[0].startTimeUnixNano),
323+
BigInt(p95Point.timeUnixNano) - BigInt(p95Point.startTimeUnixNano),
270324
).toBe(60_000_000_000n);
271-
expect(p95Point.startTimeUnixNano).toBe(
272-
count.dataPoints[0].startTimeUnixNano,
273-
);
325+
});
326+
327+
it("suppresses zero-valued cumulative counters", async () => {
328+
const { metrics } = await capture([
329+
makeEvent({
330+
eventName: "http.requests",
331+
measurements: {
332+
window_seconds: 60,
333+
count_2xx: 0,
334+
count_5xx: 0,
335+
p95_duration_ms: 10,
336+
},
337+
}),
338+
]);
339+
340+
expect(metrics.map((m) => m.name)).toEqual([
341+
"http.requests.p95_duration_ms",
342+
]);
274343
});
275344

276345
it("treats http.requests without window_seconds as a zero-width window", async () => {

0 commit comments

Comments
 (0)