diff --git a/src/commands.ts b/src/commands.ts index 745e01a68..735146562 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -307,6 +307,7 @@ export class Commands { activeProxyLogPath: this.workspaceLogPath, proxyLogDir: this.pathResolver.getProxyLogPath(), extensionLogDir: this.pathResolver.getCodeLogDir(), + telemetryDir: this.pathResolver.getTelemetryPath(), }, this.logger, ); diff --git a/src/supportBundle/logFiles.ts b/src/supportBundle/logFiles.ts index e0b284f53..f1a117623 100644 --- a/src/supportBundle/logFiles.ts +++ b/src/supportBundle/logFiles.ts @@ -6,6 +6,7 @@ import { isRemoteSshExtensionDir, isSharedChannelRemoteSshLog, } from "../remote/sshExtension"; +import * as localJsonlFiles from "../telemetry/localJsonlFiles"; import { addFiles, @@ -24,6 +25,7 @@ export interface LogSources { activeProxyLogPath?: string; proxyLogDir?: string; extensionLogDir?: string; + telemetryDir?: string; } interface WindowLogDir { @@ -63,6 +65,9 @@ export async function collectSupportLogFiles( await collectVsCodeWindowLogs(sources.extensionLogDir, logger), ); } + if (sources.telemetryDir) { + addFiles(files, await collectTelemetryFiles(sources.telemetryDir, logger)); + } return files; } @@ -119,6 +124,21 @@ export async function collectWindowLogDirs( return windows.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); } +async function collectTelemetryFiles( + telemetryDir: string, + logger: Logger, +): Promise> { + return prefixFiles( + "vscode-logs/telemetry", + await collectDirFiles( + telemetryDir, + logger, + localJsonlFiles.isFileName, + false, + ), + ); +} + async function collectProxyLogs( sources: LogSources, logger: Logger, diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts index 3f7d3e35b..3c3f41d9a 100644 --- a/src/telemetry/export/files.ts +++ b/src/telemetry/export/files.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import * as readline from "node:readline"; import { toError } from "../../error/errorUtils"; +import * as localJsonlFiles from "../localJsonlFiles"; import { parseTelemetryEventLine, TelemetryFileParseError, @@ -24,14 +25,6 @@ interface TelemetryLogFile { readonly part: number; } -/** - * Filename shape written by the sink: - * `telemetry-YYYY-MM-DD-{session}[.{part}].jsonl`. We need the date to filter - * and (session, part) to order files within a day. - */ -const TELEMETRY_FILE_PATTERN = - /^telemetry-(\d{4}-\d{2}-\d{2})-([^.]+)(?:\.(\d+))?\.jsonl$/; - /** Log files that could contain events in `range`, in chronological order. */ export async function listTelemetryFilesForRange( telemetryDir: string, @@ -107,16 +100,11 @@ function parseLogFilename( dir: string, name: string, ): TelemetryLogFile | undefined { - const match = TELEMETRY_FILE_PATTERN.exec(name); - if (!match) { + const parsed = localJsonlFiles.parseFileName(name); + if (!parsed) { return undefined; } - return { - path: path.join(dir, name), - date: match[1], - session: match[2], - part: match[3] === undefined ? 0 : Number(match[3]), - }; + return { path: path.join(dir, name), ...parsed }; } function compareLogFiles(a: TelemetryLogFile, b: TelemetryLogFile): number { diff --git a/src/telemetry/localJsonlFiles.ts b/src/telemetry/localJsonlFiles.ts new file mode 100644 index 000000000..f2bf979f9 --- /dev/null +++ b/src/telemetry/localJsonlFiles.ts @@ -0,0 +1,37 @@ +export interface ParsedFileName { + date: string; + session: string; + part: number; +} + +/** + * Filename shape written by the local JSONL sink: + * `telemetry-YYYY-MM-DD-{session}[.{part}].jsonl`. + */ +const FILE_NAME_PATTERN = + /^telemetry-(\d{4}-\d{2}-\d{2})-([^.]+)(?:\.(\d+))?\.jsonl$/; + +export function formatFileName( + date: string, + session: string, + part = 0, +): string { + const partSuffix = part > 0 ? `.${part}` : ""; + return `telemetry-${date}-${session}${partSuffix}.jsonl`; +} + +export function isFileName(name: string): boolean { + return parseFileName(name) !== undefined; +} + +export function parseFileName(name: string): ParsedFileName | undefined { + const match = FILE_NAME_PATTERN.exec(name); + if (!match) { + return undefined; + } + return { + date: match[1], + session: match[2], + part: match[3] === undefined ? 0 : Number(match[3]), + }; +} diff --git a/src/telemetry/sinks/localJsonlSink.ts b/src/telemetry/sinks/localJsonlSink.ts index b0d7e3ef9..3509334ca 100644 --- a/src/telemetry/sinks/localJsonlSink.ts +++ b/src/telemetry/sinks/localJsonlSink.ts @@ -12,14 +12,13 @@ import { cleanupFiles, type FileCleanupCandidate, } from "../../util/fileCleanup"; +import * as localJsonlFiles from "../localJsonlFiles"; import { serializeTelemetryEventLine } from "../wireFormat"; import type { Logger } from "../../logging/logger"; import type { TelemetryEvent, TelemetryLevel, TelemetrySink } from "../event"; const SINK_NAME = "local-jsonl"; -const FILE_PREFIX = "telemetry-"; -const FILE_SUFFIX = ".jsonl"; const MS_PER_DAY = 24 * 60 * 60 * 1000; export interface LocalJsonlSinkOptions { @@ -235,10 +234,13 @@ export class LocalJsonlSink implements TelemetrySink, vscode.Disposable { } #segmentPath(file: { date: string; segment: number }): string { - const seg = file.segment > 0 ? `.${file.segment}` : ""; return path.join( this.#baseDir, - `${FILE_PREFIX}${file.date}-${this.#sessionSlug}${seg}${FILE_SUFFIX}`, + localJsonlFiles.formatFileName( + file.date, + this.#sessionSlug, + file.segment, + ), ); } @@ -248,9 +250,7 @@ export class LocalJsonlSink implements TelemetrySink, vscode.Disposable { await cleanupFiles(this.#baseDir, this.#logger, { label: "telemetry file", filter: (name) => - name.startsWith(FILE_PREFIX) && - name.endsWith(FILE_SUFFIX) && - !name.includes(sessionMarker), + localJsonlFiles.isFileName(name) && !name.includes(sessionMarker), select: selectByAgeAndSize( this.#config.maxAgeDays * MS_PER_DAY, this.#config.maxTotalBytes, diff --git a/test/unit/supportBundle/logFiles.test.ts b/test/unit/supportBundle/logFiles.test.ts index d9192ae18..6133a86f3 100644 --- a/test/unit/supportBundle/logFiles.test.ts +++ b/test/unit/supportBundle/logFiles.test.ts @@ -95,6 +95,35 @@ describe("collectSupportLogFiles", () => { }); }); + it("collects recent telemetry JSONL files", async () => { + const telemetryDir = path.join(tmpDir, "telemetry"); + await fs.mkdir(telemetryDir); + await fs.writeFile( + path.join(telemetryDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"), + "recent", + ); + await fs.writeFile( + path.join(telemetryDir, "telemetry-2026-05-12-bbbbbbbb.jsonl"), + "old", + ); + await fs.writeFile(path.join(telemetryDir, "notes.jsonl"), "notes"); + await fs.mkdir(path.join(telemetryDir, "subdir")); + await setAge( + path.join(telemetryDir, "telemetry-2026-05-12-bbbbbbbb.jsonl"), + 5, + ); + + await expect(collectTextFiles({ telemetryDir })).resolves.toEqual({ + "vscode-logs/telemetry/telemetry-2026-05-12-aaaaaaaa.jsonl": "recent", + }); + }); + + it("skips missing telemetry directories", async () => { + await expect( + collectTextFiles({ telemetryDir: path.join(tmpDir, "no-such-dir") }), + ).resolves.toEqual({}); + }); + it("collects extension and Remote-SSH logs across recent VS Code sessions", async () => { const logsRoot = path.join(tmpDir, "logs"); const currentSession = "20240103T000000"; @@ -382,6 +411,32 @@ describe("collectSupportLogFiles", () => { } }, ); + + it.runIf(canTestUnreadable)( + "skips unreadable telemetry sources and includes readable telemetry files", + async () => { + const telemetryDir = path.join(tmpDir, "telemetry"); + await fs.mkdir(telemetryDir); + await fs.writeFile( + path.join(telemetryDir, "telemetry-2026-05-12-aaaaaaaa.jsonl"), + "ok", + ); + const badFile = path.join( + telemetryDir, + "telemetry-2026-05-12-bbbbbbbb.jsonl", + ); + await fs.writeFile(badFile, "secret"); + await fs.chmod(badFile, 0o000); + + try { + await expect(collectTextFiles({ telemetryDir })).resolves.toEqual({ + "vscode-logs/telemetry/telemetry-2026-05-12-aaaaaaaa.jsonl": "ok", + }); + } finally { + await fs.chmod(badFile, 0o644); + } + }, + ); }); describe("resolveLogContext", () => { diff --git a/test/unit/telemetry/localJsonlFiles.test.ts b/test/unit/telemetry/localJsonlFiles.test.ts new file mode 100644 index 000000000..16e198079 --- /dev/null +++ b/test/unit/telemetry/localJsonlFiles.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import * as localJsonlFiles from "@/telemetry/localJsonlFiles"; + +const parsedFileNameCases = [ + [ + "telemetry-2026-05-12-aaaaaaaa.jsonl", + { date: "2026-05-12", session: "aaaaaaaa", part: 0 }, + ], + [ + "telemetry-2026-05-12-aaaaaaaa.12.jsonl", + { date: "2026-05-12", session: "aaaaaaaa", part: 12 }, + ], +] as const; + +const invalidFileNames = [ + "notes.jsonl", + "telemetry-2026-05-12-aaaaaaaa.json", + "telemetry-2026-05-12-aaaaaaaa.bad.jsonl", + "telemetry-2026-05-12.jsonl", +] as const; + +describe("localJsonlFiles", () => { + it.each(parsedFileNameCases)("parses %s", (name, expected) => { + expect(localJsonlFiles.parseFileName(name)).toEqual(expected); + }); + + it.each([ + [ + "2026-05-12", + "aaaaaaaa", + undefined, + "telemetry-2026-05-12-aaaaaaaa.jsonl", + ], + ["2026-05-12", "aaaaaaaa", 0, "telemetry-2026-05-12-aaaaaaaa.jsonl"], + ["2026-05-12", "aaaaaaaa", 2, "telemetry-2026-05-12-aaaaaaaa.2.jsonl"], + ] as const)("formats %s %s part %s", (date, session, part, expected) => { + expect(localJsonlFiles.formatFileName(date, session, part)).toBe(expected); + }); + + it.each(parsedFileNameCases.map(([name]) => [name] as const))( + "matches %s", + (name) => { + expect(localJsonlFiles.isFileName(name)).toBe(true); + }, + ); + + it.each(invalidFileNames)("rejects %s", (name) => { + expect(localJsonlFiles.parseFileName(name)).toBeUndefined(); + expect(localJsonlFiles.isFileName(name)).toBe(false); + }); +});