Skip to content

Commit 55fcd6f

Browse files
Merge file-backed log sink
2 parents 883d838 + d1f9829 commit 55fcd6f

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LOG_NAMESPACE_ROOT } from "./branding.js";
33
import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/report.js";
44
import { loadConfig } from "./config/index.js";
55
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
6+
import { installFileLogSink } from "./logging/sink.js";
67
import { flushPerfToOtel } from "./perf/index.js";
78
import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js";
89
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
@@ -20,6 +21,12 @@ export async function mainWithRunners(
2021
argv: readonly string[],
2122
runners: Runners,
2223
): Promise<number> {
24+
// Must run before any other line: @intx/log installs a console sink as a
25+
// side effect of import, and loadConfig itself can log (e.g. healed
26+
// settings). Once installed, this replaces that default so nothing —
27+
// including a vendored dependency's logger — reaches the terminal the
28+
// TUI is about to own.
29+
installFileLogSink();
2330
const config = await loadConfig(argv, { allowUnconfigured: true });
2431
// Resolve the crash-report directory once, up front, while the process is
2532
// healthy. This is the only place project-key resolution (which shells

src/logging/sink.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, spyOn, test } from "bun:test";
2+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { getLogger } from "@intx/log";
6+
7+
import { corbitsLogFilePath, installFileLogSink } from "./sink.js";
8+
9+
describe("corbitsLogFilePath", () => {
10+
test("nests under the settings dir, not directly in home", () => {
11+
expect(corbitsLogFilePath("/home/dev")).toBe("/home/dev/.corbits/logs/corbits.log");
12+
});
13+
});
14+
15+
describe("installFileLogSink", () => {
16+
test("routes a logger's output to the file, never to stdout/stderr", () => {
17+
const dir = mkdtempSync(join(tmpdir(), "corbits-sink-test-"));
18+
const file = join(dir, "corbits.log");
19+
try {
20+
installFileLogSink(file);
21+
22+
const stdoutWrite = spyOn(process.stdout, "write");
23+
const stderrWrite = spyOn(process.stderr, "write");
24+
try {
25+
getLogger(["some", "vendored", "logger"]).error("boom {detail}", { detail: "bad" });
26+
} finally {
27+
stdoutWrite.mockRestore();
28+
stderrWrite.mockRestore();
29+
}
30+
31+
expect(stdoutWrite).not.toHaveBeenCalled();
32+
expect(stderrWrite).not.toHaveBeenCalled();
33+
34+
const logged = readFileSync(file, "utf8");
35+
expect(logged).toContain("boom bad");
36+
expect(logged).toContain("some.vendored.logger");
37+
} finally {
38+
rmSync(dir, { recursive: true, force: true });
39+
}
40+
});
41+
});

src/logging/sink.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { appendFileSync, mkdirSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
5+
import { configureSync } from "@intx/log";
6+
7+
import { SETTINGS_DIR_NAME } from "../branding.js";
8+
9+
// Matches LogTape's Sink shape structurally (see @logtape/logtape's
10+
// sink.d.ts); not imported directly since only @intx/log is a declared
11+
// dependency here. The real type is strictly wider than this — if LogTape
12+
// ever renames or narrows one of these fields, nothing here will catch the
13+
// drift, so keep this in sync by hand if @intx/log's pinned version moves.
14+
type LogRecord = {
15+
readonly category: readonly string[];
16+
readonly level: string;
17+
readonly message: readonly unknown[];
18+
readonly timestamp: number;
19+
readonly properties: Record<string, unknown>;
20+
};
21+
22+
export function corbitsLogFilePath(home: string = homedir()): string {
23+
return join(home, SETTINGS_DIR_NAME, "logs", "corbits.log");
24+
}
25+
26+
function formatRecord(record: LogRecord): string {
27+
return (
28+
JSON.stringify({
29+
timestamp: new Date(record.timestamp).toISOString(),
30+
level: record.level,
31+
category: record.category.join("."),
32+
message: record.message.join(""),
33+
properties: record.properties,
34+
}) + "\n"
35+
);
36+
}
37+
38+
/**
39+
* Routes every logger — including ones inside vendored dependencies, which
40+
* Corbits cannot edit — to a file instead of the console.
41+
*
42+
* `@intx/log` installs a console sink as a side effect of its first import
43+
* (see its `default-sink` module), so a bare `getLogger` import is enough
44+
* for a log call to reach stdout/stderr before Corbits does anything. This
45+
* must run before any other Corbits code executes — first statement in
46+
* `mainWithRunners` — so that race is never live: the TUI holds the
47+
* alternate screen for the rest of the process, and anything landing on
48+
* the real terminal mid-frame corrupts it.
49+
*/
50+
export function installFileLogSink(path: string = corbitsLogFilePath()): void {
51+
mkdirSync(dirname(path), { recursive: true });
52+
configureSync({
53+
reset: true,
54+
sinks: {
55+
file: (record: LogRecord) => {
56+
appendFileSync(path, formatRecord(record));
57+
},
58+
},
59+
// "debug" (not "warning"): a file has no screen to corrupt, and several
60+
// teardown-race diagnostics (e.g. src/tui/runner.ts, src/exec/runner.ts)
61+
// are logger.debug calls that exist specifically to be readable here
62+
// after the fact. Filtering them out at the sink would silently disable
63+
// the diagnostics the file exists to capture.
64+
loggers: [
65+
{ category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["file"] },
66+
{ category: [], lowestLevel: "debug", sinks: ["file"] },
67+
],
68+
});
69+
}

src/tui-opentui/log-sink.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* CL-5593: a raw structured log line from a vendored logger
3+
* (`interchange.inference.default-director`) painted itself over the prompt
4+
* box mid-frame, because nothing had ever pointed LogTape away from its
5+
* default console sink. This drives the real shell in a live session and
6+
* fires that exact logger the way the vendored code does, then asserts the
7+
* rendered frame is untouched and nothing reached stdout/stderr.
8+
*/
9+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
10+
import { mkdtempSync, rmSync, readFileSync } from "node:fs"
11+
import { tmpdir } from "node:os"
12+
import { join } from "node:path"
13+
import { getLogger } from "@intx/log"
14+
15+
import { installFileLogSink } from "../logging/sink.js"
16+
import { createAppShell } from "./shell.js"
17+
import { withTestRenderer } from "./harness.js"
18+
19+
describe("log sink during a live TUI session", () => {
20+
let logDir: string
21+
let logFile: string
22+
23+
beforeEach(() => {
24+
logDir = mkdtempSync(join(tmpdir(), "corbits-log-sink-test-"))
25+
logFile = join(logDir, "corbits.log")
26+
})
27+
28+
afterEach(() => {
29+
rmSync(logDir, { recursive: true, force: true })
30+
})
31+
32+
test("a vendored logger's error never reaches stdout, stderr, or the frame", async () => {
33+
installFileLogSink(logFile)
34+
35+
const stdoutWrite = spyOn(process.stdout, "write")
36+
const stderrWrite = spyOn(process.stderr, "write")
37+
38+
try {
39+
await withTestRenderer(async (h) => {
40+
const shell = createAppShell(h.renderer, { cwd: "/workspace/corbits-code" })
41+
await h.renderOnce()
42+
43+
const before = h.captureCharFrame()
44+
expect(before).toContain("/workspace/corbits-code")
45+
46+
// Same category and tagged-template call shape as
47+
// vendor/intx-inference's default-director.
48+
const vendoredLogger = getLogger(["interchange", "inference", "default-director"])
49+
vendoredLogger.error`Inference error in default director: ${"could not be verified"} [HTTP 400] (category: ${"fatal"})`
50+
51+
await h.renderOnce()
52+
const after = h.captureCharFrame()
53+
54+
expect(after).toContain("/workspace/corbits-code")
55+
expect(after).not.toContain("@timestamp")
56+
expect(after).not.toContain("interchange.inference.default-director")
57+
})
58+
} finally {
59+
stdoutWrite.mockRestore()
60+
stderrWrite.mockRestore()
61+
}
62+
63+
expect(stdoutWrite).not.toHaveBeenCalled()
64+
expect(stderrWrite).not.toHaveBeenCalled()
65+
66+
const logged = readFileSync(logFile, "utf8")
67+
expect(logged).toContain("interchange.inference.default-director")
68+
})
69+
})

0 commit comments

Comments
 (0)