Skip to content

Commit a98646d

Browse files
committed
feat(telemetry): skip unreadable files during export instead of failing
A corrupt telemetry file no longer aborts the whole export. The stream reports the file and moves on, keeping events already parsed from it. Each skip is logged with file:line detail, the completion notification becomes a warning naming the skip count, and the diagnostic span gains a file.skipped_count measurement (omitted at zero).
1 parent 6da258d commit a98646d

8 files changed

Lines changed: 262 additions & 69 deletions

File tree

src/instrumentation/EVENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ Emitted by `DiagnosticTelemetry` around each diagnostic command.
298298
| `interval.count` (measurement) | speed test only |
299299
| `throughput_mbits` (measurement) | speed test only |
300300
| `event.count` (measurement) | telemetry export only |
301+
| `file.skipped_count` (measurement) | telemetry export only; unreadable files skipped, omitted at zero |
301302

302303
## Deployment
303304

src/instrumentation/diagnostics.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { recordAborted, recordError } from "./outcomes";
22

33
import type { SpeedtestResult } from "@repo/shared";
44

5+
import type { ExportResult } from "../telemetry/export/pipeline";
56
import type { ExportFormat } from "../telemetry/export/writers/types";
67
import type { TelemetryReporter } from "../telemetry/reporter";
78
import type { Span } from "../telemetry/span";
@@ -29,7 +30,7 @@ export interface DiagnosticTrace {
2930
error(category?: DiagnosticErrorCategory): void;
3031
setRequestedDuration(seconds: number): void;
3132
succeedSpeedtest(result: SpeedtestResult): void;
32-
succeedExport(format: ExportFormat, eventCount: number): void;
33+
succeedExport(format: ExportFormat, result: ExportResult): void;
3334
}
3435

3536
/** Emits `command.diagnostic.completed` around each diagnostic command. */
@@ -71,8 +72,11 @@ class SpanDiagnosticTrace implements DiagnosticTrace {
7172
);
7273
}
7374

74-
public succeedExport(format: ExportFormat, eventCount: number): void {
75+
public succeedExport(format: ExportFormat, result: ExportResult): void {
7576
this.span.setProperty("format", format);
76-
this.span.setMeasurement("event.count", eventCount);
77+
this.span.setMeasurement("event.count", result.eventCount);
78+
if (result.skippedFileCount > 0) {
79+
this.span.setMeasurement("file.skipped_count", result.skippedFileCount);
80+
}
7781
}
7882
}

src/telemetry/export/command.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import {
1111
collectTelemetryExport,
1212
type ExportRequest,
13+
type ExportResult,
1314
type ExportRuntime,
1415
} from "./pipeline";
1516
import { promptForExport, type ExportChoice } from "./prompts";
@@ -21,8 +22,6 @@ import type { FlushStatus } from "../service";
2122

2223
import type { ExportFormat } from "./writers/types";
2324

24-
const REVEAL_ACTION = "Reveal in File Explorer";
25-
2625
const PROGRESS_OPTIONS = {
2726
location: vscode.ProgressLocation.Notification,
2827
title: "Exporting Coder telemetry",
@@ -36,7 +35,7 @@ const PROGRESS_OPTIONS = {
3635
export interface ExportTelemetryObserver {
3736
abort(stage: "prompt" | "progress"): void;
3837
error(): void;
39-
succeedExport(format: ExportFormat, eventCount: number): void;
38+
succeedExport(format: ExportFormat, result: ExportResult): void;
4039
}
4140

4241
export async function runExportTelemetryCommand(
@@ -87,12 +86,15 @@ function exportRuntime(
8786
),
8887
onCleanupError: (err, target) =>
8988
logger.warn("Failed to clean up after telemetry export", target, err),
89+
// The skipped file's name and line are in the error message.
90+
onFileSkipped: (err) =>
91+
logger.warn("Telemetry export skipped an unreadable file", err),
9092
};
9193
}
9294

9395
/** Turns the export result into the matching user-facing notification. */
9496
async function reportOutcome(
95-
result: ProgressResult<number>,
97+
result: ProgressResult<ExportResult>,
9698
choice: ExportChoice,
9799
logger: Logger,
98100
observer: ExportTelemetryObserver,
@@ -110,29 +112,59 @@ async function reportOutcome(
110112
return;
111113
}
112114

113-
const eventCount = result.value;
114-
observer.succeedExport(choice.format, eventCount);
115+
const { eventCount, skippedFileCount } = result.value;
116+
observer.succeedExport(choice.format, result.value);
115117
if (eventCount === 0) {
116-
void vscode.window.showInformationMessage(
118+
void showExportMessage(
117119
`No telemetry events found for ${choice.range.label}.`,
120+
skippedFileCount,
121+
logger,
118122
);
119123
return;
120124
}
121-
await notifyExportSucceeded(choice.outputPath, eventCount, logger);
125+
const action = await showExportMessage(
126+
`Exported ${formatEventCount(eventCount)} to ${choice.outputPath}.`,
127+
skippedFileCount,
128+
logger,
129+
"Reveal in File Explorer",
130+
);
131+
if (action === "Reveal in File Explorer") {
132+
await revealExportedFile(choice.outputPath, logger);
133+
}
122134
}
123135

124-
async function notifyExportSucceeded(
125-
outputPath: string,
126-
eventCount: number,
136+
type ExportAction = "Reveal in File Explorer";
137+
138+
/**
139+
* A partial export warns and names the unreadable-file count. "Show Output"
140+
* is offered on warnings and handled here; only caller actions are returned.
141+
*/
142+
async function showExportMessage(
143+
message: string,
144+
skippedFileCount: number,
127145
logger: Logger,
128-
): Promise<void> {
129-
const action = await vscode.window.showInformationMessage(
130-
`Exported ${formatEventCount(eventCount)} to ${outputPath}.`,
131-
REVEAL_ACTION,
146+
...actions: ExportAction[]
147+
): Promise<ExportAction | undefined> {
148+
if (skippedFileCount === 0) {
149+
return vscode.window.showInformationMessage(message, ...actions);
150+
}
151+
const files = skippedFileCount === 1 ? "file" : "files";
152+
const action = await vscode.window.showWarningMessage(
153+
`${message} ${skippedFileCount} ${files} could not be read. Check the Coder output for details.`,
154+
...actions,
155+
"Show Output",
132156
);
133-
if (action !== REVEAL_ACTION) {
134-
return;
157+
if (action === "Show Output") {
158+
logger.show();
159+
return undefined;
135160
}
161+
return action;
162+
}
163+
164+
async function revealExportedFile(
165+
outputPath: string,
166+
logger: Logger,
167+
): Promise<void> {
136168
try {
137169
await vscode.commands.executeCommand(
138170
"revealFileInOS",

src/telemetry/export/files.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ export async function listTelemetryFilesForRange(
5050
/**
5151
* Yields events from `filePaths` in order, keeping only those whose timestamp
5252
* falls inside `range`. Reads line-by-line so memory stays flat on big files.
53+
* A file that cannot be read or parsed is reported through `onFileError` and
54+
* skipped from its first bad line, so one bad file cannot sink an export.
5355
*/
5456
export async function* streamTelemetryEvents(
5557
filePaths: readonly string[],
5658
range: TelemetryDateRange,
59+
onFileError: (err: Error, fileName: string) => void,
5760
): AsyncIterable<TelemetryEvent> {
5861
for (const filePath of filePaths) {
5962
const name = path.basename(filePath);
@@ -76,13 +79,15 @@ export async function* streamTelemetryEvents(
7679
}
7780
}
7881
} catch (err) {
79-
if (err instanceof TelemetryFileParseError) {
80-
throw err;
81-
}
8282
const at = lineNumber > 0 ? `:${lineNumber}` : "";
83-
throw new Error(
84-
`Failed to read telemetry file ${name}${at}: ${toError(err).message}`,
85-
{ cause: err },
83+
onFileError(
84+
err instanceof TelemetryFileParseError
85+
? err
86+
: new Error(
87+
`Failed to read telemetry file ${name}${at}: ${toError(err).message}`,
88+
{ cause: err },
89+
),
90+
name,
8691
);
8792
} finally {
8893
try {

src/telemetry/export/pipeline.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export interface ExportRequest {
1717
readonly writer: ExportWriter;
1818
}
1919

20+
export interface ExportResult {
21+
readonly eventCount: number;
22+
/** Files skipped because they could not be read or parsed. */
23+
readonly skippedFileCount: number;
24+
}
25+
2026
/**
2127
* Host hooks the export needs: cancellation, flush, progress, and a cleanup
2228
* callback. Free of VS Code types so the pipeline is testable without a UI.
@@ -27,19 +33,22 @@ export interface ExportRuntime {
2733
readonly report: (message: string) => void;
2834
/** The pre-export flush did not fully succeed, so recent events may be missing. */
2935
readonly onFlushIncomplete: () => void;
36+
/** A file could not be read or parsed; the export continues without it (caller logs). */
37+
readonly onFileSkipped: (err: Error, fileName: string) => void;
3038
/** A temp file, staging dir, or empty export could not be removed (caller logs). */
3139
readonly onCleanupError: (err: unknown, target: string) => void;
3240
}
3341

3442
/**
3543
* Flushes telemetry, then streams every event in the range to `outputPath`.
36-
* Returns the number written; an export matching nothing leaves no file
37-
* behind. Throws on cancellation or write failure.
44+
* Returns the count written and any unreadable files skipped; an export
45+
* matching nothing leaves no file behind. Throws on cancellation or write
46+
* failure.
3847
*/
3948
export async function collectTelemetryExport(
4049
request: ExportRequest,
4150
runtime: ExportRuntime,
42-
): Promise<number> {
51+
): Promise<ExportResult> {
4352
runtime.report("Flushing buffered events...");
4453
const flushStatus = await runtime.flushTelemetry();
4554
throwIfAborted(runtime.signal);
@@ -53,12 +62,16 @@ export async function collectTelemetryExport(
5362
request.range,
5463
);
5564
if (filePaths.length === 0) {
56-
return 0;
65+
return { eventCount: 0, skippedFileCount: 0 };
5766
}
5867

5968
runtime.report("Writing export...");
69+
let skippedFileCount = 0;
6070
const events = abortable(
61-
streamTelemetryEvents(filePaths, request.range),
71+
streamTelemetryEvents(filePaths, request.range, (err, fileName) => {
72+
skippedFileCount += 1;
73+
runtime.onFileSkipped(err, fileName);
74+
}),
6275
runtime.signal,
6376
);
6477
const eventCount = await request.writer(
@@ -70,7 +83,7 @@ export async function collectTelemetryExport(
7083
if (eventCount === 0) {
7184
await removeEmptyExport(request.outputPath, runtime.onCleanupError);
7285
}
73-
return eventCount;
86+
return { eventCount, skippedFileCount };
7487
}
7588

7689
/** Removes the file a writer produced for an export that matched no events. */

test/unit/telemetry/export/command.test.ts

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const OK_FLUSH: FlushStatus = { ok: true, sinks: [] };
4040
function setup(
4141
opts: {
4242
choice?: ExportChoice;
43-
outcome?: { count: number } | { error: unknown };
43+
outcome?: { count: number; skipped?: number } | { error: unknown };
4444
revealChoice?: string;
4545
} = {},
4646
) {
@@ -56,21 +56,26 @@ function setup(
5656
if ("error" in outcome) {
5757
vi.mocked(collectTelemetryExport).mockRejectedValue(outcome.error);
5858
} else {
59-
vi.mocked(collectTelemetryExport).mockResolvedValue(outcome.count);
59+
vi.mocked(collectTelemetryExport).mockResolvedValue({
60+
eventCount: outcome.count,
61+
skippedFileCount: outcome.skipped ?? 0,
62+
});
6063
}
6164

6265
const observer: ExportTelemetryObserver = {
6366
abort: vi.fn(),
6467
error: vi.fn(),
6568
succeedExport: vi.fn(),
6669
};
70+
const logger = createMockLogger();
6771

6872
return {
6973
observer,
74+
logger,
7075
run: () =>
7176
runExportTelemetryCommand(
7277
TELEMETRY_DIR,
73-
createMockLogger(),
78+
logger,
7479
vi.fn(() => Promise.resolve(OK_FLUSH)),
7580
context,
7681
observer,
@@ -115,7 +120,7 @@ describe("runExportTelemetryCommand", () => {
115120
vi.mocked(collectTelemetryExport).mockImplementation(
116121
(_request, runtime) => {
117122
onFlushIncomplete = runtime.onFlushIncomplete;
118-
return Promise.resolve(2);
123+
return Promise.resolve({ eventCount: 2, skippedFileCount: 0 });
119124
},
120125
);
121126

@@ -136,14 +141,42 @@ describe("runExportTelemetryCommand", () => {
136141

137142
await run();
138143

139-
expect(observer.succeedExport).toHaveBeenCalledWith("json", count);
144+
expect(observer.succeedExport).toHaveBeenCalledWith("json", {
145+
eventCount: count,
146+
skippedFileCount: 0,
147+
});
140148

141149
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
142150
`${message} ${OUTPUT_PATH}.`,
143151
REVEAL_ACTION,
144152
);
145153
});
146154

155+
it("warns and names the skip count when files were skipped", async () => {
156+
const { run } = setup({ outcome: { count: 2, skipped: 1 } });
157+
158+
await run();
159+
160+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
161+
`Exported 2 telemetry events to ${OUTPUT_PATH}. 1 file could not be read. Check the Coder output for details.`,
162+
REVEAL_ACTION,
163+
"Show Output",
164+
);
165+
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled();
166+
});
167+
168+
it("opens the Coder output when Show Output is clicked", async () => {
169+
const { logger, run } = setup({ outcome: { count: 2, skipped: 1 } });
170+
vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(
171+
"Show Output" as never,
172+
);
173+
174+
await run();
175+
176+
expect(logger.show).toHaveBeenCalled();
177+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
178+
});
179+
147180
it("reveals the file when the user clicks the action", async () => {
148181
const { run } = setup({ revealChoice: REVEAL_ACTION });
149182

@@ -181,12 +214,26 @@ describe("runExportTelemetryCommand", () => {
181214

182215
await run();
183216

184-
expect(observer.succeedExport).toHaveBeenCalledWith("json", 0);
217+
expect(observer.succeedExport).toHaveBeenCalledWith("json", {
218+
eventCount: 0,
219+
skippedFileCount: 0,
220+
});
185221

186222
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(
187223
"No telemetry events found for Last 24 hours.",
188224
);
189225
});
226+
227+
it("warns when every matching file was skipped", async () => {
228+
const { run } = setup({ outcome: { count: 0, skipped: 2 } });
229+
230+
await run();
231+
232+
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
233+
"No telemetry events found for Last 24 hours. 2 files could not be read. Check the Coder output for details.",
234+
"Show Output",
235+
);
236+
});
190237
});
191238

192239
describe("failure", () => {

0 commit comments

Comments
 (0)