diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index 0a9f3a7cdfb..af7300e0c70 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -442,4 +442,32 @@ describe("diagnostic support export", () => { expect(combined).toContain("status snapshot failed"); expect(combined).toContain("health snapshot failed"); }); + + it("keeps writing when log tail collection fails", async () => { + const fakeToken = "sk-test-log-tail-secret-token-1234567890"; + const outputPath = path.join(tempDir, "support-failed-log-tail.zip"); + + await writeDiagnosticSupportExport({ + env: { + ...process.env, + HOME: tempDir, + OPENCLAW_STATE_DIR: tempDir, + }, + stateDir: tempDir, + outputPath, + now: new Date("2026-04-22T12:00:02.000Z"), + readLogTail: async () => { + throw new Error(`log tail failed at ${tempDir}/openclaw.log with token ${fakeToken}`); + }, + }); + + const entries = await readZipTextEntries(outputPath); + expect(Object.keys(entries).toSorted()).toContain("logs/openclaw-sanitized.jsonl"); + + const combined = Object.values(entries).join("\n"); + expect(combined).not.toContain(fakeToken); + expect(combined).not.toContain(tempDir); + expect(combined).toContain("log-tail-read-failed"); + expect(combined).toContain("sanitized log tail unavailable"); + }); }); diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index b2ca6a05631..ef8db59d08a 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -133,12 +133,14 @@ type ConfigExport = { }; type SanitizedLogTail = { + status: "included" | "failed"; file: string; cursor: number; size: number; lineCount: number; truncated: boolean; reset: boolean; + error?: string; lines: Array>; }; @@ -332,8 +334,8 @@ function readConfigExport(options: { } } -function redactErrorForSupport(error: unknown): string { - return redactTextForSupport(error instanceof Error ? error.message : String(error)); +function redactErrorForSupport(error: unknown, redaction: SupportRedactionContext): string { + return redactSupportString(error instanceof Error ? error.message : String(error), redaction); } async function collectSupportSnapshot(params: { @@ -359,7 +361,7 @@ async function collectSupportSnapshot(params: { }), }; } catch (error) { - const redactedError = redactErrorForSupport(error); + const redactedError = redactErrorForSupport(error, params.redaction); return { summary: { status: "failed", @@ -571,6 +573,7 @@ function isSafeLogField(key: string, value: unknown): boolean { function sanitizeLogTail(tail: LogTailPayload, options: SupportRedactionContext): SanitizedLogTail { return { + status: "included", file: redactPathForSupport(tail.file, options), cursor: tail.cursor, size: tail.size, @@ -581,6 +584,39 @@ function sanitizeLogTail(tail: LogTailPayload, options: SupportRedactionContext) }; } +async function collectSupportLogTail(params: { + readLogTail: typeof readConfiguredLogTail; + limit: number; + maxBytes: number; + redaction: SupportRedactionContext; +}): Promise { + try { + const tail = await params.readLogTail({ + limit: params.limit, + maxBytes: params.maxBytes, + }); + return sanitizeLogTail(tail, params.redaction); + } catch (error) { + const redactedError = redactErrorForSupport(error, params.redaction); + return { + status: "failed", + file: "unavailable", + cursor: 0, + size: 0, + lineCount: 0, + truncated: false, + reset: false, + error: redactedError, + lines: [ + { + omitted: "log-tail-read-failed", + error: redactedError, + }, + ], + }; + } +} + function describeStabilityForDiagnostics( stability: ReadDiagnosticStabilityBundleResult, redaction: SupportRedactionContext, @@ -606,7 +642,7 @@ function describeStabilityForDiagnostics( return { status: "failed" as const, path: stability.path ? redactPathForSupport(stability.path, redaction) : undefined, - error: redactErrorForSupport(stability.error), + error: redactErrorForSupport(stability.error, redaction), }; } @@ -647,7 +683,9 @@ function renderSummary(params: { "## Contents", "", `- ${stabilityLine}`, - `- sanitized log tail (${params.logTail.lineCount} line(s), inspected ${params.logTail.size} byte(s), raw messages omitted)`, + params.logTail.status === "failed" + ? `- sanitized log tail unavailable (${params.logTail.error})` + : `- sanitized log tail (${params.logTail.lineCount} line(s), inspected ${params.logTail.size} byte(s), raw messages omitted)`, `- ${configLine}`, `- ${supportSnapshotLine("gateway status", params.status)}`, `- ${supportSnapshotLine("gateway health", params.health)}`, @@ -718,11 +756,12 @@ export async function buildDiagnosticSupportExport( const configPath = resolveConfigPath(env, stateDir); const stability = readStabilityBundle(options.stabilityBundle, stateDir); const redaction = { env, stateDir }; - const tail = await (options.readLogTail ?? readConfiguredLogTail)({ + const logTail = await collectSupportLogTail({ + readLogTail: options.readLogTail ?? readConfiguredLogTail, limit: normalizePositiveInteger(options.logLimit, DEFAULT_LOG_LIMIT), maxBytes: normalizePositiveInteger(options.logMaxBytes, DEFAULT_LOG_MAX_BYTES), + redaction, }); - const logTail = sanitizeLogTail(tail, redaction); const config = readConfigExport({ configPath, env, stateDir }); const [statusSnapshot, healthSnapshot] = await Promise.all([ collectSupportSnapshot({