Skip to content

Commit 3e01527

Browse files
committed
refactor(telemetry): slim the export manifest and drop sink health
Self-review follow-ups on the export hardening: - Drop sink health from the manifest. It was session-cumulative and could not be scoped to the export's date range, so it implied an accuracy it could not deliver. Removes getSinkHealth, TelemetrySink.health, and the sink's drop/flush counters; flush and overflow failures are still logged. - Make the manifest a required OTLP write option and remove the includeManifest flag threaded through the zip pipeline. - Slim the manifest types and take `format` as a parameter so the builder is reusable across formats. - Trim route-normalization comments and share a writer test helper.
1 parent ac5ec21 commit 3e01527

13 files changed

Lines changed: 113 additions & 376 deletions

File tree

src/commands.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,6 @@ export class Commands {
359359
this.pathResolver.getTelemetryPath(),
360360
this.logger,
361361
() => this.telemetryService.flush(),
362-
() => this.telemetryService.getSinkHealth(),
363362
this.telemetryService.getContext(),
364363
);
365364
}

src/logging/routeNormalization.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
/**
2-
* Normalizes request and websocket routes into low-cardinality labels for
3-
* telemetry. Two safety goals drive this: never leak secrets (query strings
4-
* and fragments can carry tokens) and never persist an unbounded label (ids
5-
* collapse and unknown routes bucket), so even a route the extension has
6-
* never seen stays safe to emit.
2+
* Normalizes request and websocket routes into low-cardinality telemetry
3+
* labels. Drops the query/fragment (which can carry tokens) and bounds
4+
* cardinality by collapsing ids and bucketing unmatched routes, so even an
5+
* unseen route is safe to emit.
76
*/
87

98
const UNKNOWN_ROUTE = "<unknown>";
@@ -20,10 +19,9 @@ const UUID_PATTERN =
2019
const INTEGER_PATTERN = /^\d+$/;
2120

2221
/**
23-
* Templates refine name segments (usernames, workspace and template names)
24-
* that generic id collapsing cannot catch. This is a precision aid only:
25-
* unknown routes are already bounded by id collapsing and bucketing, so a
26-
* missing rule never risks correctness or cardinality.
22+
* Templates refine name segments (usernames, workspace/template names) that
23+
* id collapsing misses. Precision only: a missing rule never risks
24+
* cardinality, since unmatched routes still collapse ids and bucket.
2725
*/
2826
const ROUTE_NORMALIZATION_RULES: ReadonlyArray<readonly string[]> = [
2927
"api/v2/users/{name}/workspace/{name}",
@@ -109,9 +107,8 @@ function normalizeByRule(
109107
}
110108

111109
/**
112-
* Bucket for routes with no matching template: keeps a short resource-path
113-
* prefix and collapses the rest, so unseen routes stay bounded even when
114-
* their variable segments are names rather than ids.
110+
* Bucket for unmatched routes: keep a short prefix and collapse the rest, so
111+
* unseen routes stay bounded even when their variable segments are names.
115112
*/
116113
function bucketUnmatchedRoute(segments: readonly string[]): string {
117114
if (segments.length <= UNMATCHED_PREFIX_SEGMENTS) {

src/telemetry/event.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,6 @@ export type CallerMeasurements = Record<string, number> & {
2727
durationMs?: never;
2828
};
2929

30-
/**
31-
* In-memory health counters a sink may expose for export diagnostics. All
32-
* counters are cumulative for the life of the sink and never reset.
33-
*/
34-
export interface SinkHealth {
35-
/** Events dropped because the in-memory buffer overflowed. */
36-
readonly droppedBufferOverflow: number;
37-
/** Events dropped because a flush write failed. */
38-
readonly droppedWriteFailure: number;
39-
/** Flushes that threw. */
40-
readonly failedFlushes: number;
41-
/** Category (errno code or error name) of the most recent flush error. */
42-
readonly lastErrorCategory: string | null;
43-
}
44-
4530
/**
4631
* Sink for telemetry events. `write` is sync and must buffer in memory; I/O
4732
* happens in `flush`/`dispose`. The service filters by `minLevel`; sinks can
@@ -53,8 +38,6 @@ export interface TelemetrySink {
5338
write(event: TelemetryEvent): void;
5439
flush(): Promise<void>;
5540
dispose(): Promise<void>;
56-
/** Optional in-memory health counters for export diagnostics. */
57-
health?(): SinkHealth;
5841
}
5942

6043
/** Build session attributes from the extension version and ambient host data. */

src/telemetry/export/command.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { writeJsonArrayExport } from "./writers/json";
1919
import { writeOtlpZipExport } from "./writers/otlp/writer";
2020

2121
import type { Logger } from "../../logging/logger";
22-
import type { SinkHealth, TelemetryContext } from "../event";
22+
import type { TelemetryContext } from "../event";
2323
import type { FlushStatus } from "../service";
2424

2525
interface FormatPick extends vscode.QuickPickItem {
@@ -69,7 +69,6 @@ export async function runExportTelemetryCommand(
6969
telemetryDir: string,
7070
logger: Logger,
7171
flushTelemetry: () => Promise<FlushStatus>,
72-
getSinkHealth: () => SinkHealth,
7372
context: TelemetryContext,
7473
): Promise<void> {
7574
const range = await promptDateRange();
@@ -144,7 +143,6 @@ export async function runExportTelemetryCommand(
144143
endMs: range.endMs,
145144
},
146145
sourceFiles: filePaths.length,
147-
sinkHealth: getSinkHealth(),
148146
},
149147
},
150148
);

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

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,73 @@
11
import { CURRENT_TELEMETRY_SCHEMA_VERSION } from "../../../wireFormat";
22

3-
import type { SinkHealth, TelemetryContext } from "../../../event";
3+
import type { TelemetryContext } from "../../../event";
44

5-
/** File name of the manifest packed alongside the OTLP envelopes. */
5+
/** File name of the manifest packed alongside the export envelopes. */
66
export const MANIFEST_FILE = "manifest.json";
77

8-
/**
9-
* Version of the manifest document's own shape, separate from the telemetry
10-
* row version (`CURRENT_TELEMETRY_SCHEMA_VERSION`) it reports. Bump when the
11-
* manifest structure changes.
12-
*/
8+
/** Manifest document format version; bump when this shape changes. */
139
export const MANIFEST_SCHEMA_VERSION = 1;
1410

15-
/** Range portion of the manifest input, mirroring `TelemetryDateRange`. */
16-
export interface ManifestRangeInput {
11+
/** Date range the export covers. */
12+
export interface ManifestRange {
1713
readonly label: string;
1814
readonly startMs?: number;
1915
readonly endMs?: number;
2016
}
2117

22-
/** Caller-supplied inputs the writer cannot derive from the event stream. */
23-
export interface ManifestInput {
24-
readonly range: ManifestRangeInput;
25-
readonly sourceFiles: number;
26-
readonly sinkHealth: SinkHealth;
27-
}
28-
29-
/** OTLP record counts per signal (records written, not source events). */
18+
/** Per-signal record counts (records written, not source events). */
3019
export interface RecordCounts {
3120
readonly logs: number;
3221
readonly traces: number;
3322
readonly metrics: number;
3423
}
3524

25+
/** Caller-supplied inputs the writer cannot derive from the event stream. */
26+
export interface ManifestInput {
27+
readonly range: ManifestRange;
28+
readonly sourceFiles: number;
29+
}
30+
31+
/** Self-describing metadata written alongside an export. */
3632
export interface ExportManifest {
37-
/** Manifest document format version. */
3833
readonly schemaVersion: number;
39-
/** Schema version of the exported telemetry rows. */
4034
readonly telemetrySchemaVersion: number;
4135
readonly exportedAt: string;
4236
readonly extensionVersion: string;
37+
readonly format: string;
4338
readonly range: {
4439
readonly label: string;
4540
readonly start: string | null;
4641
readonly end: string | null;
4742
};
48-
readonly format: "otlp-json";
4943
readonly sourceFiles: number;
5044
readonly sourceEvents: number;
5145
readonly records: RecordCounts;
52-
readonly sinkHealth: {
53-
readonly droppedEvents: number;
54-
readonly failedFlushes: number;
55-
readonly lastErrorCategory: string | null;
56-
};
5746
}
5847

5948
export function buildManifest(args: {
60-
readonly input: ManifestInput;
49+
readonly format: string;
6150
readonly context: TelemetryContext;
51+
readonly input: ManifestInput;
6252
readonly sourceEvents: number;
6353
readonly records: RecordCounts;
6454
readonly exportedAt: string;
6555
}): ExportManifest {
66-
const { input, context, sourceEvents, records, exportedAt } = args;
67-
const { sinkHealth } = input;
56+
const { format, context, input, sourceEvents, records, exportedAt } = args;
6857
return {
6958
schemaVersion: MANIFEST_SCHEMA_VERSION,
7059
telemetrySchemaVersion: CURRENT_TELEMETRY_SCHEMA_VERSION,
7160
exportedAt,
7261
extensionVersion: context.extensionVersion,
62+
format,
7363
range: {
7464
label: input.range.label,
7565
start: toIso(input.range.startMs),
7666
end: toIso(input.range.endMs),
7767
},
78-
format: "otlp-json",
7968
sourceFiles: input.sourceFiles,
8069
sourceEvents,
8170
records,
82-
sinkHealth: {
83-
droppedEvents:
84-
sinkHealth.droppedBufferOverflow + sinkHealth.droppedWriteFailure,
85-
failedFlushes: sinkHealth.failedFlushes,
86-
lastErrorCategory: sinkHealth.lastErrorCategory,
87-
},
8871
};
8972
}
9073

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

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,17 @@ export interface OtlpExportCounts {
4444
}
4545

4646
export interface OtlpWriteOptions {
47+
/** Manifest metadata, written as `manifest.json` inside the zip. */
48+
readonly manifest: ManifestInput;
4749
readonly signal?: AbortSignal;
4850
readonly onTempCleanupError?: (err: unknown, tempPath: string) => void;
4951
/** Fires on either success or failure path so cleanup errors never mask the export outcome. */
5052
readonly onStagingCleanupError?: (err: unknown, dir: string) => void;
51-
/** When provided, a `manifest.json` describing the export is added to the zip. */
52-
readonly manifest?: ManifestInput;
5353
}
5454

55+
/** OTLP/JSON format tag recorded in the manifest. */
56+
const OTLP_FORMAT = "otlp-json";
57+
5558
interface Channel {
5659
file: EnvelopeFile;
5760
/** Source events routed to this signal. */
@@ -74,7 +77,7 @@ export async function writeOtlpZipExport(
7477
outputPath: string,
7578
events: AsyncIterable<TelemetryEvent>,
7679
context: TelemetryContext,
77-
options: OtlpWriteOptions = {},
80+
options: OtlpWriteOptions,
7881
): Promise<OtlpExportCounts> {
7982
throwIfAborted(options.signal);
8083
return writeAtomically(
@@ -92,12 +95,7 @@ export async function writeOtlpZipExport(
9295
options.signal,
9396
options.manifest,
9497
);
95-
await packZip(
96-
zipPath,
97-
stagingDir,
98-
options.signal,
99-
options.manifest !== undefined,
100-
);
98+
await packZip(zipPath, stagingDir, options.signal);
10199
} catch (err) {
102100
await safeRemove(stagingDir, options.onStagingCleanupError);
103101
throw err;
@@ -129,7 +127,7 @@ async function writeStagedFiles(
129127
events: AsyncIterable<TelemetryEvent>,
130128
context: TelemetryContext,
131129
signal: AbortSignal | undefined,
132-
manifestInput: ManifestInput | undefined,
130+
manifestInput: ManifestInput,
133131
): Promise<OtlpExportCounts> {
134132
const resource = JSON.stringify(otlpResource(context));
135133
const scope = JSON.stringify(otlpScope(context.extensionVersion));
@@ -155,11 +153,7 @@ async function writeStagedFiles(
155153
traces: channels.traces.count,
156154
metrics: channels.metrics.count,
157155
};
158-
159-
if (manifestInput) {
160-
await writeManifest(dir, manifestInput, context, channels);
161-
}
162-
156+
await writeManifest(dir, manifestInput, context, channels);
163157
return counts;
164158
}
165159

@@ -177,6 +171,7 @@ async function writeManifest(
177171
const sourceEvents =
178172
channels.logs.count + channels.traces.count + channels.metrics.count;
179173
const manifest = buildManifest({
174+
format: OTLP_FORMAT,
180175
input,
181176
context,
182177
sourceEvents,
@@ -283,11 +278,10 @@ async function packZip(
283278
outputPath: string,
284279
sourceDir: string,
285280
signal: AbortSignal | undefined,
286-
includeManifest: boolean,
287281
): Promise<void> {
288282
const outStream = createWriteStream(outputPath);
289283
try {
290-
await streamEnvelopesIntoZip(outStream, sourceDir, signal, includeManifest);
284+
await streamEnvelopesIntoZip(outStream, sourceDir, signal);
291285
} catch (err) {
292286
outStream.destroy();
293287
if (isAbortError(err)) {
@@ -302,7 +296,6 @@ function streamEnvelopesIntoZip(
302296
outStream: WriteStream,
303297
sourceDir: string,
304298
signal: AbortSignal | undefined,
305-
includeManifest: boolean,
306299
): Promise<void> {
307300
return new Promise<void>((resolve, reject) => {
308301
const fail = (err: unknown): void => reject(toError(err));
@@ -331,13 +324,7 @@ function streamEnvelopesIntoZip(
331324
}
332325
});
333326

334-
void pumpEnvelopes(
335-
zip,
336-
sourceDir,
337-
signal,
338-
waitForDrain,
339-
includeManifest,
340-
).catch((err) => {
327+
void pumpEnvelopes(zip, sourceDir, signal, waitForDrain).catch((err) => {
341328
zip.terminate();
342329
fail(err);
343330
});
@@ -349,14 +336,11 @@ async function pumpEnvelopes(
349336
sourceDir: string,
350337
signal: AbortSignal | undefined,
351338
waitForDrain: () => Promise<void>,
352-
includeManifest: boolean,
353339
): Promise<void> {
354-
const names: string[] = Object.values(ENVELOPES).map(
355-
(envelope) => envelope.file,
356-
);
357-
if (includeManifest) {
358-
names.push(MANIFEST_FILE);
359-
}
340+
const names = [
341+
...Object.values(ENVELOPES).map((envelope) => envelope.file),
342+
MANIFEST_FILE,
343+
];
360344
for (const name of names) {
361345
throwIfAborted(signal);
362346
await streamFileIntoZip(

src/telemetry/service.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
type CallerProperties,
1414
type CallerPropertyValue,
1515
type SessionContext,
16-
type SinkHealth,
1716
type TelemetryContext,
1817
type TelemetryEvent,
1918
type TelemetryLevel,
@@ -67,13 +66,6 @@ export interface FlushStatus {
6766
readonly sinks: readonly SinkFlushResult[];
6867
}
6968

70-
const EMPTY_SINK_HEALTH: SinkHealth = {
71-
droppedBufferOverflow: 0,
72-
droppedWriteFailure: 0,
73-
failedFlushes: 0,
74-
lastErrorCategory: null,
75-
};
76-
7769
/**
7870
* Emits structured telemetry events to a fan-out of sinks. Sinks are filtered
7971
* by `minLevel` and may self-gate. `dispose` flushes are best-effort since
@@ -189,24 +181,6 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter {
189181
return { ok: sinks.every((s) => s.ok), sinks };
190182
}
191183

192-
/** Aggregate health across sinks that report it, for export diagnostics. */
193-
public getSinkHealth(): SinkHealth {
194-
return this.sinks.reduce<SinkHealth>((acc, sink) => {
195-
const health = sink.health?.();
196-
if (!health) {
197-
return acc;
198-
}
199-
return {
200-
droppedBufferOverflow:
201-
acc.droppedBufferOverflow + health.droppedBufferOverflow,
202-
droppedWriteFailure:
203-
acc.droppedWriteFailure + health.droppedWriteFailure,
204-
failedFlushes: acc.failedFlushes + health.failedFlushes,
205-
lastErrorCategory: health.lastErrorCategory ?? acc.lastErrorCategory,
206-
};
207-
}, EMPTY_SINK_HEALTH);
208-
}
209-
210184
public async dispose(): Promise<void> {
211185
this.#configWatcher.dispose();
212186
await this.flush();

0 commit comments

Comments
 (0)