Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@
"axios": "1.15.0",
"date-fns": "catalog:",
"eventsource": "^4.1.0",
"fflate": "^0.8.2",
"find-process": "^2.1.1",
"jsonc-parser": "^3.3.1",
"openpgp": "^6.3.0",
Expand Down
16 changes: 12 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { type ServiceContainer } from "./core/container";
import { type MementoManager } from "./core/mementoManager";
import { type PathResolver } from "./core/pathResolver";
import { type SecretsManager } from "./core/secretsManager";
import { appendVsCodeLogs } from "./core/supportBundleLogs";
import { type DeploymentManager } from "./deployment/deploymentManager";
import { CertificateError } from "./error/certificateError";
import { toError } from "./error/errorUtils";
Expand Down Expand Up @@ -256,6 +257,18 @@ export class Commands {

progress.report({ message: "Collecting diagnostics..." });
await cliExec.supportBundle(env, workspaceId, outputUri.fsPath, signal);

progress.report({ message: "Adding VS Code logs..." });
await appendVsCodeLogs(
Comment thread
EhabY marked this conversation as resolved.
outputUri.fsPath,
{
remoteSshLogPath: this.workspaceLogPath,
proxyLogDir: this.pathResolver.getProxyLogPath(),
extensionLogDir: this.pathResolver.getCodeLogDir(),
},
this.logger,
);

return outputUri;
},
{
Expand Down
158 changes: 158 additions & 0 deletions src/core/supportBundleLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { unzip, zip } from "fflate";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";

import { type Logger } from "../logging/logger";
import { renameWithRetry } from "../util";

export interface LogSources {
remoteSshLogPath?: string;
proxyLogDir?: string;
extensionLogDir?: string;
}

// 3 days is enough context for recent issues; matching the 7-day
// rotation would bloat the bundle.
const LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
Comment thread
EhabY marked this conversation as resolved.

const unzipAsync = promisify(unzip);
const zipAsync = promisify(zip);

async function collectDirFiles(
dirPath: string,
logger: Logger,
): Promise<Map<string, Uint8Array>> {
const results = new Map<string, Uint8Array>();

let entries: string[];
try {
entries = await fs.readdir(dirPath);
} catch (error) {
logger.warn(`Could not read log directory ${dirPath}`, error);
return results;
}

const cutoff = Date.now() - LOG_MAX_AGE_MS;

await Promise.all(
entries.map(async (entry) => {
const filePath = path.join(dirPath, entry);
try {
const stat = await fs.stat(filePath);
if (!stat.isFile() || stat.mtimeMs < cutoff) {
return;
}
results.set(entry, await fs.readFile(filePath));
} catch (error) {
logger.warn(`Could not read log file ${filePath}`, error);
}
}),
);

return results;
}

/**
* Gather log files from each source independently so a failure in one
* does not affect the others.
*/
async function collectLogFiles(
sources: LogSources,
logger: Logger,
): Promise<Map<string, Uint8Array>> {
const files = new Map<string, Uint8Array>();

if (sources.remoteSshLogPath) {
try {
files.set(
Comment thread
EhabY marked this conversation as resolved.
`vscode-logs/remote-ssh/${path.basename(sources.remoteSshLogPath)}`,
await fs.readFile(sources.remoteSshLogPath),
);
} catch (error) {
logger.warn("Could not read Remote SSH log", error);
}
}

if (sources.proxyLogDir) {
for (const [name, data] of await collectDirFiles(
sources.proxyLogDir,
logger,
)) {
files.set(`vscode-logs/proxy/${name}`, data);
}
}

if (sources.extensionLogDir) {
for (const [name, data] of await collectDirFiles(
sources.extensionLogDir,
logger,
)) {
files.set(`vscode-logs/extension/${name}`, data);
}
}

return files;
}

/**
* Best-effort: append VS Code logs to a support bundle zip.
* Uses atomic rename to avoid corrupting the original bundle on failure.
*/
export async function appendVsCodeLogs(
zipPath: string,
sources: LogSources,
logger: Logger,
): Promise<void> {
try {
const logFiles = await collectLogFiles(sources, logger);
if (logFiles.size === 0) {
logger.info("No VS Code logs found to add to support bundle");
return;
}

logger.info(
`Adding ${logFiles.size} VS Code log file(s) to support bundle`,
);

// Write to a named temporary path first so a failure at the rename step
// leaves the user with a properly named file containing VS Code logs.
const parsed = path.parse(zipPath);
const vscodeBundlePath = path.join(
parsed.dir,
`${parsed.name}-vscode${parsed.ext}`,
);

try {
const entries = await unzipAsync(await fs.readFile(zipPath));
for (const [name, data] of logFiles) {
entries[name] = data;
}
const updated = await zipAsync(entries);
await fs.writeFile(vscodeBundlePath, updated);
} catch (error) {
logger.error("Failed to add VS Code logs to support bundle", error);
try {
await fs.rm(vscodeBundlePath, { force: true });
} catch (cleanupError) {
logger.warn(
`Could not clean up partial bundle at ${vscodeBundlePath}`,
cleanupError,
);
}
return;
}

try {
await renameWithRetry(fs.rename, vscodeBundlePath, zipPath);
} catch (error) {
logger.warn(
`Could not replace original bundle; VS Code logs saved separately at ${vscodeBundlePath}`,
error,
);
}
} catch (error) {
// Best-effort: never let a failure here lose the user's bundle.
logger.error("Unexpected error appending VS Code logs", error);
}
}
Loading