Skip to content

Commit efc3596

Browse files
Merge pull request #1430 from corbitsdev/cl-10661-logging-cleanup-collapse-storms-fix-levels-add-correla
refactor(logging): collapse double-log storms, fix levels, correlate ids
2 parents 6ad8089 + eee06d1 commit efc3596

20 files changed

Lines changed: 320 additions & 216 deletions

‎src/cost/pricing-fetcher.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,7 @@ export function startPricingRefresh(
306306
const refreshIntervalMs =
307307
options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
308308
const timer = setInterval(() => {
309-
loadPricing(options).catch((err: unknown) => {
310-
process.stderr.write(`pricing-fetcher: refresh error: ${err}\n`);
311-
});
309+
void loadPricing(options);
312310
}, refreshIntervalMs);
313311
return timer;
314312
}

‎src/cost/pricing-metadata.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,9 @@ export function schedulePricingMetadataRefresh(
2929
): void {
3030
if (refreshScheduled) return;
3131
refreshScheduled = true;
32-
void loadPricing(options)
33-
.then((cache) => {
34-
if (cache !== null) applyPricingCacheMetadata(cache);
35-
})
36-
.catch((err: unknown) => {
37-
// Match pricing-fetcher: keep refresh best-effort, surface the failure.
38-
process.stderr.write(`pricing-metadata: refresh error: ${err}\n`);
39-
});
32+
void loadPricing(options).then((cache) => {
33+
if (cache !== null) applyPricingCacheMetadata(cache);
34+
});
4035
}
4136

4237
export async function bootstrapPricingMetadata(

‎src/exec/dispose.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getLogger } from "@intx/log";
22

33
import { LOG_NAMESPACE_ROOT } from "../branding.js";
4+
import { caughtDebug } from "../logging/caught.js";
45
import { awaitCloseWithoutHidingLeftover } from "../subagent/dispose.js";
56
import type { SubAgentSessionStore } from "../subagent/index.js";
67

@@ -59,9 +60,11 @@ async function runExecDispose(args: {
5960
try {
6061
await args.toolset.dispose();
6162
} catch (err: unknown) {
62-
logger.debug("toolset.dispose during exec finally failed: {error}", {
63-
error: (args.sanitizeDiagnostic ?? String)(formatCaughtError(err)),
64-
});
63+
caughtDebug(
64+
logger,
65+
"toolset.dispose during exec finally failed",
66+
args.sanitizeDiagnostic,
67+
)(err);
6568
failures.push(err);
6669
}
6770
}
@@ -74,9 +77,11 @@ async function runExecDispose(args: {
7477
try {
7578
await awaitCloseWithoutHidingLeftover(args.agent.close(), failures[0]);
7679
} catch (err: unknown) {
77-
logger.debug("agent.close during exec finally failed: {error}", {
78-
error: (args.sanitizeDiagnostic ?? String)(formatCaughtError(err)),
79-
});
80+
caughtDebug(
81+
logger,
82+
"agent.close during exec finally failed",
83+
args.sanitizeDiagnostic,
84+
)(err);
8085
failures.push(err);
8186
}
8287
}

‎src/exec/runner.ts‎

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "../subagent/index.js";
4343
import { getProcessAdmissionQueue } from "../subagent/admission.js";
4444
import { disposeExecRuntime, formatCaughtError } from "./dispose.js";
45+
import { caughtDebug } from "../logging/caught.js";
4546
import {
4647
EXEC_MCP_CONNECT_WAIT_MS,
4748
EXEC_MCP_HANDSHAKE_TIMEOUT_MS,
@@ -469,7 +470,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
469470
priorState = prior?.kind === "ok" ? prior.state : undefined;
470471
} catch (err) {
471472
const message = formatCaughtError(err);
472-
logger.error("exec setup failed: {error}", { error: message });
473+
logger.error("exec setup failed: {error}", {
474+
sessionId,
475+
error: message,
476+
});
473477
stderr.write(`Error: ${message}\n`);
474478
liveTelemetry.capture(
475479
"session_end",
@@ -623,11 +627,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
623627

624628
try {
625629
// Pricing seed is optional for exec; continue without rates.
626-
const inferenceDeps = await assembleInferenceBase((err: unknown) => {
627-
logger.debug("seedPricingMetadataFromCache failed: {error}", {
628-
error: sanitizeExecDiagnostic(formatCaughtError(err)),
629-
});
630-
});
630+
const inferenceDeps = await assembleInferenceBase(
631+
caughtDebug(
632+
logger,
633+
"seedPricingMetadataFromCache failed",
634+
sanitizeExecDiagnostic,
635+
),
636+
);
631637

632638
// One-shot migration only when the path-trust file is absent. Headless
633639
// exec has no frame to corrupt, so skipped members write straight to stderr.
@@ -1246,22 +1252,22 @@ export async function runExec(config: Config): Promise<ExecResult> {
12461252
// entry and would write the whole reply as a spurious partial.
12471253
// Dispose is a no-op on success and a real record only if a cycle
12481254
// died without a terminal event.
1249-
await activeAgent.close().catch((err: unknown) => {
1250-
logger.debug(
1251-
"agent.close during successful-send teardown failed: {error}",
1252-
{
1253-
error: sanitizeExecDiagnostic(formatCaughtError(err)),
1254-
},
1255-
);
1256-
});
1257-
await streamPromise.catch((err: unknown) => {
1258-
logger.debug(
1259-
"stream drain during successful-send teardown failed: {error}",
1260-
{
1261-
error: sanitizeExecDiagnostic(formatCaughtError(err)),
1262-
},
1255+
await activeAgent
1256+
.close()
1257+
.catch(
1258+
caughtDebug(
1259+
logger,
1260+
"agent.close during successful-send teardown failed",
1261+
sanitizeExecDiagnostic,
1262+
),
12631263
);
1264-
});
1264+
await streamPromise.catch(
1265+
caughtDebug(
1266+
logger,
1267+
"stream drain during successful-send teardown failed",
1268+
sanitizeExecDiagnostic,
1269+
),
1270+
);
12651271
await cycleRecorder.dispose("cancelled");
12661272
} else {
12671273
// Failed or aborted send: close() tears down stream consumers before
@@ -1270,22 +1276,22 @@ export async function runExec(config: Config): Promise<ExecResult> {
12701276
await cycleRecorder.dispose(
12711277
sendCompleted ? "cancelled" : "send-failed",
12721278
);
1273-
await activeAgent.close().catch((err: unknown) => {
1274-
logger.debug(
1275-
"agent.close during failed-send teardown failed: {error}",
1276-
{
1277-
error: sanitizeExecDiagnostic(formatCaughtError(err)),
1278-
},
1279-
);
1280-
});
1281-
await streamPromise.catch((err: unknown) => {
1282-
logger.debug(
1283-
"stream drain during failed-send teardown failed: {error}",
1284-
{
1285-
error: sanitizeExecDiagnostic(formatCaughtError(err)),
1286-
},
1279+
await activeAgent
1280+
.close()
1281+
.catch(
1282+
caughtDebug(
1283+
logger,
1284+
"agent.close during failed-send teardown failed",
1285+
sanitizeExecDiagnostic,
1286+
),
12871287
);
1288-
});
1288+
await streamPromise.catch(
1289+
caughtDebug(
1290+
logger,
1291+
"stream drain during failed-send teardown failed",
1292+
sanitizeExecDiagnostic,
1293+
),
1294+
);
12891295
}
12901296
}
12911297

@@ -1379,7 +1385,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
13791385
return result;
13801386
} catch (err) {
13811387
const diagnosticMessage = sanitizeExecDiagnostic(formatCaughtError(err));
1382-
logger.error("exec failed: {error}", { error: diagnosticMessage });
1388+
logger.error("exec failed: {error}", {
1389+
sessionId,
1390+
error: diagnosticMessage,
1391+
});
13831392
const userMessage = sanitizeExecDiagnostic(
13841393
execUserFailureMessage(
13851394
config,
@@ -1420,7 +1429,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
14201429
});
14211430
} catch (err: unknown) {
14221431
const message = sanitizeExecDiagnostic(formatCaughtError(err));
1423-
logger.error("runtime dispose failed: {error}", { error: message });
1432+
logger.error("runtime dispose failed: {error}", {
1433+
sessionId,
1434+
error: message,
1435+
});
14241436
stderr.write(`Error: runtime dispose failed: ${message}\n`);
14251437
if (result !== undefined) {
14261438
result.exitCode = 1;

‎src/logging/caught.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { caughtDebug } from "./caught.js";
4+
5+
// @intx/log renders the {error} template at sink level, so capture both the
6+
// message template and the properties to pin the normalization-and-append
7+
// contract.
8+
describe("caughtDebug", () => {
9+
interface Capture {
10+
message: string;
11+
properties: Record<string, unknown> | undefined;
12+
}
13+
const capture = {
14+
debug: (message: string, properties?: Record<string, unknown>) => {
15+
capture.last = { message, properties };
16+
},
17+
last: undefined as Capture | undefined,
18+
};
19+
20+
test("Error values feed {error} from .message", () => {
21+
caughtDebug(capture, "teardown failed")(new Error("boom"));
22+
expect(capture.last?.message).toBe("teardown failed: {error}");
23+
expect(capture.last?.properties).toEqual({ error: "boom" });
24+
});
25+
26+
test("non-Error values are String()ed", () => {
27+
caughtDebug(capture, "teardown failed")("boom-string");
28+
expect(capture.last?.properties).toEqual({ error: "boom-string" });
29+
});
30+
31+
test("a sanitizer runs on the normalized error message", () => {
32+
caughtDebug(capture, "teardown failed", (error) =>
33+
error.replaceAll("secret", "[redacted]"),
34+
)(new Error("secret leaked"));
35+
expect(capture.last?.properties).toEqual({
36+
error: "[redacted] leaked",
37+
});
38+
});
39+
});

‎src/logging/caught.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
interface DebugSink {
2+
debug: (message: string, properties?: Record<string, unknown>) => void;
3+
}
4+
5+
export function caughtDebug(
6+
logger: DebugSink,
7+
message: string,
8+
sanitize?: (error: string) => string,
9+
): (err: unknown) => void {
10+
return (err) => {
11+
const error = err instanceof Error ? err.message : String(err);
12+
logger.debug(`${message}: {error}`, { error: (sanitize ?? String)(error) });
13+
};
14+
}

‎src/perf/otel-sink.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,9 @@ export async function flushPerfToOtel(
270270
try {
271271
const resolved = resolveOtelExportConfig(settings, env);
272272
if (!resolved.ok) {
273-
log.warn("OTEL export skipped: {message}", { message: resolved.message });
273+
log.debug("OTEL export skipped: {message}", {
274+
message: resolved.message,
275+
});
274276
return;
275277
}
276278
config = resolved.config;

‎src/permission/approval-store-migration.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "node:fs/promises";
1010
import { tmpdir } from "node:os";
1111
import { dirname, join } from "node:path";
12+
import { installFileLogSink } from "../logging/sink.js";
1213
import { generateSessionId, sessionDir } from "../session/index.js";
1314
import { loadSeededApprovals } from "../session/runtime-assembly.js";
1415
import { normalizeSeededApprovals } from "./authz-grants.js";
@@ -297,6 +298,56 @@ describe("migratePersistedApprovalStores", () => {
297298
}
298299
});
299300

301+
test("per-file migration failures surface once as a single summary warn", async () => {
302+
const logDir = await mkdtemp(join(tmpdir(), "approval-migration-log-"));
303+
installFileLogSink(join(logDir, "corbits.log"));
304+
const sessionOriginal = {
305+
approvals: [
306+
{ tool: "update_plan", pattern: "plan *" },
307+
{ tool: "run_shell", pattern: "npm *" },
308+
],
309+
};
310+
await writeFile(sessionStorePath(), JSON.stringify(sessionOriginal));
311+
await mkdir(join(cwd, ".corbits"), { recursive: true });
312+
await writeFile(
313+
projectStorePath(),
314+
JSON.stringify({
315+
approvals: [{ tool: "update_plan", pattern: "plan *" }],
316+
}),
317+
);
318+
// Read-only store dirs make the backup step fail, so both stores skip
319+
// untouched while the global path (absent) stays a clean no-op.
320+
const sessionStoreDir = dirname(sessionStorePath());
321+
const projectStoreDir = join(cwd, ".corbits");
322+
await chmod(sessionStoreDir, 0o555);
323+
await chmod(projectStoreDir, 0o555);
324+
try {
325+
const result = await migratePersistedApprovalStores(cwd, sessionId, home);
326+
expect(result.purged).toBe(0);
327+
const failed = result.files.filter((file) => file.error !== undefined);
328+
expect(failed).toHaveLength(2);
329+
for (const file of failed) {
330+
expect(file.error).toContain("backup failed");
331+
expect(file.purged).toBe(0);
332+
}
333+
334+
const lines = (await readFile(join(logDir, "corbits.log"), "utf8"))
335+
.split("\n")
336+
.filter((line) => line.includes("Skipped approval-store migration"));
337+
// One summary warn for the whole migration, not one per failing store.
338+
expect(lines).toHaveLength(1);
339+
const summary = lines[0];
340+
if (summary === undefined) throw new Error("summary warn missing");
341+
expect(summary).toMatch(/"level":"warning"/);
342+
expect(summary).toContain(sessionStorePath());
343+
expect(summary).toContain(projectStorePath());
344+
} finally {
345+
await chmod(sessionStoreDir, 0o755);
346+
await chmod(projectStoreDir, 0o755);
347+
await rm(logDir, { recursive: true, force: true });
348+
}
349+
});
350+
300351
test("existing torn .bak plus live update_plan still purges live", async () => {
301352
const path = sessionStorePath();
302353
const original = {

0 commit comments

Comments
 (0)