Files
openclaw/src/logging/diagnostic-support-export.ts
scoootscooob a3d9c53db2 feat: add trajectory bundle export and default-on runtime capture (#70291)
* Trajectory: export session bundles by default

* Harden trajectory export diagnostics integration

* Address trajectory export review feedback

* Share diagnostics and trajectory bundle plumbing

* Harden trajectory recording and export

* Confine trajectory export outputs

* Document trajectory export command

* Harden trajectory export bundle privacy

* Redact trajectory sidecar paths

* Fix plugin install checks after rebase

* Keep queued writers working without O_NOFOLLOW

* Keep Codex trajectory writes without O_NOFOLLOW

* Harden trajectory export path handling

* Redact mixed trajectory export paths
2026-04-22 23:29:01 -07:00

693 lines
20 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { parseConfigJson5 } from "../config/io.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { redactConfigObject } from "../config/redact-snapshot.js";
import { resolveHomeRelativePath } from "../infra/home-dir.js";
import { VERSION } from "../version.js";
import {
readDiagnosticStabilityBundleFileSync,
readLatestDiagnosticStabilityBundleSync,
type ReadDiagnosticStabilityBundleResult,
} from "./diagnostic-stability-bundle.js";
import {
jsonSupportBundleFile,
jsonlSupportBundleFile,
supportBundleContents,
textSupportBundleFile,
writeSupportBundleZip,
type DiagnosticSupportBundleContent,
type DiagnosticSupportBundleFile,
} from "./diagnostic-support-bundle.js";
import { sanitizeSupportLogRecord } from "./diagnostic-support-log-redaction.js";
import {
redactPathForSupport,
redactSupportString,
redactTextForSupport,
sanitizeSupportConfigValue,
sanitizeSupportSnapshotValue,
type SupportRedactionContext,
} from "./diagnostic-support-redaction.js";
import { readConfiguredLogTail, type LogTailPayload } from "./log-tail.js";
export const DIAGNOSTIC_SUPPORT_EXPORT_VERSION = 1;
const DEFAULT_LOG_LIMIT = 5000;
const DEFAULT_LOG_MAX_BYTES = 1_000_000;
const SUPPORT_EXPORT_PREFIX = "openclaw-diagnostics-";
const SUPPORT_EXPORT_SUFFIX = ".zip";
type Awaitable<T> = T | Promise<T>;
type SupportSnapshotReader = () => Awaitable<unknown>;
export type DiagnosticSupportExportOptions = {
outputPath?: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
now?: Date;
logLimit?: number;
logMaxBytes?: number;
stabilityBundle?: string | false;
readLogTail?: typeof readConfiguredLogTail;
readStatusSnapshot?: SupportSnapshotReader;
readHealthSnapshot?: SupportSnapshotReader;
};
export type DiagnosticSupportExportManifest = {
version: typeof DIAGNOSTIC_SUPPORT_EXPORT_VERSION;
generatedAt: string;
openclawVersion: string;
platform: NodeJS.Platform;
arch: string;
node: string;
stateDir: string;
contents: DiagnosticSupportBundleContent[];
privacy: {
payloadFree: true;
rawLogsIncluded: false;
notes: string[];
};
};
export type DiagnosticSupportExportFile = DiagnosticSupportBundleFile;
export type DiagnosticSupportExportArtifact = {
manifest: DiagnosticSupportExportManifest;
files: DiagnosticSupportExportFile[];
};
export type WriteDiagnosticSupportExportResult = {
path: string;
bytes: number;
manifest: DiagnosticSupportExportManifest;
};
type ConfigShape = {
path: string;
exists: boolean;
parseOk: boolean;
bytes?: number;
mtime?: string;
error?: string;
topLevelKeys: string[];
gateway?: {
mode?: unknown;
bind?: unknown;
port?: unknown;
authMode?: unknown;
tailscale?: unknown;
};
channels?: {
count: number;
ids: string[];
};
plugins?: {
count: number;
ids: string[];
};
agents?: {
count: number;
};
};
type ConfigExport = {
shape: ConfigShape;
sanitized?: unknown;
};
type IncludedSanitizedLogTail = {
status: "included";
file: string;
cursor: number;
size: number;
lineCount: number;
truncated: boolean;
reset: boolean;
lines: Array<Record<string, unknown>>;
};
type FailedSanitizedLogTail = Omit<IncludedSanitizedLogTail, "status"> & {
status: "failed";
error: string;
};
type SanitizedLogTail = IncludedSanitizedLogTail | FailedSanitizedLogTail;
type SupportSnapshotStatus =
| {
status: "included";
path: string;
}
| {
status: "failed";
path: string;
error: string;
}
| {
status: "skipped";
};
type CollectedSupportSnapshot = {
summary: SupportSnapshotStatus;
file?: DiagnosticSupportExportFile;
};
function formatExportTimestamp(now: Date): string {
return now.toISOString().replace(/[:.]/g, "-");
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return Math.floor(parsed);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function safeScalar(value: unknown): unknown {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const redacted = redactTextForSupport(value);
return redacted === value && /^[A-Za-z0-9_.:-]{1,120}$/u.test(value) ? value : "<redacted>";
}
return undefined;
}
function sortedObjectKeys(value: unknown): string[] {
return Object.keys(asRecord(value) ?? {}).toSorted((a, b) => a.localeCompare(b));
}
function sanitizeConfigShape(parsed: unknown, configPath: string, stat: fs.Stats): ConfigShape {
const root = asRecord(parsed) ?? {};
const gateway = asRecord(root.gateway);
const auth = asRecord(gateway?.auth);
const channels = asRecord(root.channels);
const plugins = asRecord(root.plugins);
const agents = Array.isArray(root.agents) ? root.agents : undefined;
const shape: ConfigShape = {
path: configPath,
exists: true,
parseOk: true,
bytes: stat.size,
mtime: stat.mtime.toISOString(),
topLevelKeys: sortedObjectKeys(root),
};
if (gateway) {
shape.gateway = {
mode: safeScalar(gateway.mode),
bind: safeScalar(gateway.bind),
port: safeScalar(gateway.port),
authMode: safeScalar(auth?.mode),
tailscale: safeScalar(gateway.tailscale),
};
}
if (channels) {
shape.channels = {
count: Object.keys(channels).length,
ids: sortedObjectKeys(channels),
};
}
if (plugins) {
shape.plugins = {
count: Object.keys(plugins).length,
ids: sortedObjectKeys(plugins),
};
}
if (agents) {
shape.agents = { count: agents.length };
}
return shape;
}
function sanitizeConfigDetails(parsed: unknown, redaction: SupportRedactionContext): unknown {
return sanitizeSupportConfigValue(redactConfigObject(parsed), redaction);
}
function configShapeReadFailure(params: {
configPath: string;
redaction: SupportRedactionContext;
stat?: fs.Stats;
error?: string;
}): ConfigShape {
const shape: ConfigShape = {
path: params.configPath,
exists: Boolean(params.stat),
parseOk: false,
topLevelKeys: [],
};
if (params.stat) {
shape.bytes = params.stat.size;
shape.mtime = params.stat.mtime.toISOString();
}
if (params.error) {
shape.error = redactSupportString(params.error, params.redaction);
}
return shape;
}
function isMissingPathError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("code" in error)) {
return false;
}
return error.code === "ENOENT" || error.code === "ENOTDIR";
}
function configReadErrorMessage(error: unknown, stat?: fs.Stats): string | undefined {
if (!stat && isMissingPathError(error)) {
return undefined;
}
return error instanceof Error ? error.message : String(error);
}
function readConfigExport(options: {
configPath: string;
env: NodeJS.ProcessEnv;
stateDir: string;
}): ConfigExport {
const redactedConfigPath = redactPathForSupport(options.configPath, options);
let stat: fs.Stats | undefined;
try {
stat = fs.statSync(options.configPath);
const parsed = parseConfigJson5(fs.readFileSync(options.configPath, "utf8"));
if (!parsed.ok) {
return {
shape: configShapeReadFailure({
configPath: redactedConfigPath,
redaction: options,
stat,
error: parsed.error,
}),
};
}
return {
shape: sanitizeConfigShape(parsed.parsed, redactedConfigPath, stat),
sanitized: sanitizeConfigDetails(parsed.parsed, options),
};
} catch (error) {
return {
shape: configShapeReadFailure({
configPath: redactedConfigPath,
redaction: options,
stat,
error: configReadErrorMessage(error, stat),
}),
};
}
}
function redactErrorForSupport(error: unknown, redaction: SupportRedactionContext): string {
return redactSupportString(error instanceof Error ? error.message : String(error), redaction);
}
async function collectSupportSnapshot(params: {
path: string;
reader?: SupportSnapshotReader;
generatedAt: string;
redaction: SupportRedactionContext;
}): Promise<CollectedSupportSnapshot> {
if (!params.reader) {
return { summary: { status: "skipped" } };
}
try {
const data = await params.reader();
return {
summary: {
status: "included",
path: params.path,
},
file: jsonSupportBundleFile(params.path, {
status: "ok",
capturedAt: params.generatedAt,
data: sanitizeSupportSnapshotValue(data, params.redaction),
}),
};
} catch (error) {
const redactedError = redactErrorForSupport(error, params.redaction);
return {
summary: {
status: "failed",
path: params.path,
error: redactedError,
},
file: jsonSupportBundleFile(params.path, {
status: "failed",
capturedAt: params.generatedAt,
error: redactedError,
}),
};
}
}
function readStabilityBundle(
target: DiagnosticSupportExportOptions["stabilityBundle"],
stateDir: string,
): ReadDiagnosticStabilityBundleResult {
if (target === false) {
return { status: "missing", dir: "$OPENCLAW_STATE_DIR/logs/stability" };
}
if (target === undefined || target === "latest") {
return readLatestDiagnosticStabilityBundleSync({ stateDir });
}
return readDiagnosticStabilityBundleFileSync(target);
}
function sanitizeLogTail(tail: LogTailPayload, options: SupportRedactionContext): SanitizedLogTail {
return {
status: "included",
file: redactPathForSupport(tail.file, options),
cursor: tail.cursor,
size: tail.size,
lineCount: tail.lines.length,
truncated: tail.truncated,
reset: tail.reset,
lines: tail.lines.map((line) => sanitizeSupportLogRecord(line, options)),
};
}
function failedLogTail(error: unknown, redaction: SupportRedactionContext): SanitizedLogTail {
const redactedError = redactErrorForSupport(error, 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,
},
],
};
}
async function collectSupportLogTail(params: {
readLogTail: typeof readConfiguredLogTail;
limit: number;
maxBytes: number;
redaction: SupportRedactionContext;
}): Promise<SanitizedLogTail> {
try {
const tail = await params.readLogTail({
limit: params.limit,
maxBytes: params.maxBytes,
});
return sanitizeLogTail(tail, params.redaction);
} catch (error) {
return failedLogTail(error, params.redaction);
}
}
function describeStabilityForDiagnostics(
stability: ReadDiagnosticStabilityBundleResult,
redaction: SupportRedactionContext,
) {
if (stability.status === "found") {
return {
status: "found" as const,
path: redactPathForSupport(stability.path, redaction),
mtimeMs: stability.mtimeMs,
eventCount: stability.bundle.snapshot.count,
reason: stability.bundle.reason,
generatedAt: stability.bundle.generatedAt,
};
}
if (stability.status === "missing") {
return {
status: "missing" as const,
dir: redactPathForSupport(stability.dir, redaction),
};
}
return {
status: "failed" as const,
path: stability.path ? redactPathForSupport(stability.path, redaction) : undefined,
error: redactErrorForSupport(stability.error, redaction),
};
}
function renderSummary(params: {
generatedAt: string;
stability: ReadDiagnosticStabilityBundleResult;
logTail: SanitizedLogTail;
config: ConfigShape;
status: SupportSnapshotStatus;
health: SupportSnapshotStatus;
}): string {
const stabilityLine =
params.stability.status === "found"
? `included latest stability bundle (${params.stability.bundle.snapshot.count} event(s))`
: `no stability bundle included (${params.stability.status})`;
const configLine = params.config.exists
? `config shape included (${params.config.parseOk ? "parsed" : "parse failed"})`
: "config file not found";
const logTailLine =
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)`;
const supportSnapshotLine = (label: string, snapshot: SupportSnapshotStatus) => {
if (snapshot.status === "included") {
return `${label} snapshot included (${snapshot.path})`;
}
if (snapshot.status === "failed") {
return `${label} snapshot failed (${snapshot.error})`;
}
return `${label} snapshot skipped`;
};
return [
"# OpenClaw Diagnostics Export",
"",
"Attach this zip to the bug report. It is designed for maintainers to inspect without asking for raw logs first.",
"",
"## Generated",
"",
`Generated: ${params.generatedAt}`,
`OpenClaw: ${VERSION}`,
"",
"## Contents",
"",
`- ${stabilityLine}`,
`- ${logTailLine}`,
`- ${configLine}`,
`- ${supportSnapshotLine("gateway status", params.status)}`,
`- ${supportSnapshotLine("gateway health", params.health)}`,
"",
"## Maintainer Quick Read",
"",
"- `manifest.json`: file inventory and privacy notes",
"- `diagnostics.json`: top-level summary of config, logs, stability, status, and health",
"- `config/sanitized.json`: config values with credentials, private identifiers, and prompt text redacted",
"- `status/gateway-status.json`: sanitized service/connectivity snapshot",
"- `health/gateway-health.json`: sanitized Gateway health snapshot",
"- `logs/openclaw-sanitized.jsonl`: sanitized log summaries and metadata",
"- `stability/latest.json`: newest payload-free stability bundle, when available",
"",
"## Privacy",
"",
"- raw chat text, webhook bodies, tool outputs, tokens, cookies, and secrets are not included intentionally",
"- log records keep operational summaries and safe metadata fields",
"- status and health snapshots redact secret fields, payload-like fields, and account/message identifiers",
"- config output keeps useful settings but redacts secrets, private identifiers, and prompt text",
].join("\n");
}
function defaultOutputPath(options: { now: Date; stateDir: string }): string {
return path.join(
options.stateDir,
"logs",
"support",
`${SUPPORT_EXPORT_PREFIX}${formatExportTimestamp(options.now)}-${process.pid}${SUPPORT_EXPORT_SUFFIX}`,
);
}
function resolveOutputPath(options: {
outputPath?: string;
cwd: string;
env: NodeJS.ProcessEnv;
stateDir: string;
now: Date;
}): string {
const raw = options.outputPath?.trim();
if (!raw) {
return defaultOutputPath(options);
}
const resolved =
path.isAbsolute(raw) || raw.startsWith("~")
? resolveHomeRelativePath(raw, { env: options.env })
: path.resolve(options.cwd, raw);
try {
if (fs.statSync(resolved).isDirectory()) {
return path.join(
resolved,
`${SUPPORT_EXPORT_PREFIX}${formatExportTimestamp(options.now)}-${process.pid}${SUPPORT_EXPORT_SUFFIX}`,
);
}
} catch {
// Non-existing output paths are treated as files.
}
return resolved;
}
export async function buildDiagnosticSupportExport(
options: DiagnosticSupportExportOptions = {},
): Promise<DiagnosticSupportExportArtifact> {
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
const now = options.now ?? new Date();
const generatedAt = now.toISOString();
const configPath = resolveConfigPath(env, stateDir);
const stability = readStabilityBundle(options.stabilityBundle, stateDir);
const redaction = { env, stateDir };
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 config = readConfigExport({ configPath, env, stateDir });
const [statusSnapshot, healthSnapshot] = await Promise.all([
collectSupportSnapshot({
path: "status/gateway-status.json",
reader: options.readStatusSnapshot,
generatedAt,
redaction,
}),
collectSupportSnapshot({
path: "health/gateway-health.json",
reader: options.readHealthSnapshot,
generatedAt,
redaction,
}),
]);
const diagnostics = {
generatedAt,
openclawVersion: VERSION,
process: {
platform: process.platform,
arch: process.arch,
node: process.versions.node,
pid: process.pid,
},
stateDir: redactPathForSupport(stateDir, redaction),
config: config.shape,
logs: {
file: logTail.file,
cursor: logTail.cursor,
size: logTail.size,
lineCount: logTail.lineCount,
truncated: logTail.truncated,
reset: logTail.reset,
},
stability: describeStabilityForDiagnostics(stability, redaction),
status: statusSnapshot.summary,
health: healthSnapshot.summary,
};
const files: DiagnosticSupportExportFile[] = [
jsonSupportBundleFile("diagnostics.json", diagnostics),
jsonSupportBundleFile("config/shape.json", config.shape),
jsonSupportBundleFile("config/sanitized.json", config.sanitized ?? null),
jsonlSupportBundleFile(
"logs/openclaw-sanitized.jsonl",
logTail.lines.map((line) => JSON.stringify(line)),
),
];
for (const snapshot of [statusSnapshot, healthSnapshot]) {
if (snapshot.file) {
files.push(snapshot.file);
}
}
if (stability.status === "found") {
files.push(jsonSupportBundleFile("stability/latest.json", stability.bundle));
}
files.push(
textSupportBundleFile(
"summary.md",
renderSummary({
generatedAt,
stability,
logTail,
config: config.shape,
status: statusSnapshot.summary,
health: healthSnapshot.summary,
}),
),
);
const manifest: DiagnosticSupportExportManifest = {
version: DIAGNOSTIC_SUPPORT_EXPORT_VERSION,
generatedAt,
openclawVersion: VERSION,
platform: process.platform,
arch: process.arch,
node: process.versions.node,
stateDir: redactPathForSupport(stateDir, redaction),
contents: supportBundleContents(files),
privacy: {
payloadFree: true,
rawLogsIncluded: false,
notes: [
"Stability bundles are payload-free diagnostic snapshots.",
"Logs keep operational summaries and safe metadata fields; payload-like fields are omitted.",
"Status and health snapshots redact secrets, payload-like fields, and account/message identifiers.",
"Config output includes useful settings with credentials, private identifiers, and prompt text redacted.",
],
},
};
return {
manifest,
files: [jsonSupportBundleFile("manifest.json", manifest), ...files],
};
}
export async function writeDiagnosticSupportExport(
options: DiagnosticSupportExportOptions = {},
): Promise<WriteDiagnosticSupportExportResult> {
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
const now = options.now ?? new Date();
const outputPath = resolveOutputPath({
outputPath: options.outputPath,
cwd: options.cwd ?? process.cwd(),
env,
stateDir,
now,
});
const artifact = await buildDiagnosticSupportExport({ ...options, env, stateDir, now });
const bytes = await writeSupportBundleZip({
outputPath,
files: artifact.files,
compressionLevel: 6,
});
return {
path: outputPath,
bytes,
manifest: artifact.manifest,
};
}