feat: add centralized LogTape logger module with dev/autonomous modes, PII redaction, and per-session file rotation

- Install @logtape/logtape, @logtape/pretty, @logtape/file, @logtape/redaction
- Create src/logger.ts with configureLogger() and getLogger() exports
- Dev mode: pretty console output with debug level
- Autonomous mode: JSON console + rotating file sink in .sf/logs/{sessionId}/
- PII redaction for API keys (sk-*, key-*, Bearer *) and home directory paths
- Category hierarchy: sf.core, sf.uok, sf.autonomous, sf.extension, sf.web
- Comprehensive tests in src/tests/logger.test.ts (10 tests)
- Wire configureLogger() into src/cli.ts startup path

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-08 19:58:11 +02:00
parent 8f02524fd7
commit fd06629f06
5 changed files with 1468 additions and 4 deletions

1019
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,10 @@
"@clack/prompts": "^1.1.0",
"@google/gemini-cli-core": "0.40.1",
"@google/genai": "^1.40.0",
"@logtape/file": "^2.0.7",
"@logtape/logtape": "^2.0.7",
"@logtape/pretty": "^2.0.7",
"@logtape/redaction": "^2.0.7",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",

View file

@ -24,6 +24,7 @@ import {
import { error, formatStructuredError } from "./errors.js";
import { printHelp, printSubcommandHelp } from "./help-text.js";
import { acquireInteractiveSessionLock } from "./interactive-session-lock.js";
import { configureLogger } from "./logger.js";
import { runOnboarding, shouldRunOnboarding } from "./onboarding.js";
import { migratePiCredentials } from "./pi-migration.js";
import { getProjectSessionsDir } from "./project-sessions.js";
@ -52,6 +53,18 @@ if (parseInt(process.versions.node.split(".")[0], 10) >= 22) {
process.env.NODE_COMPILE_CACHE ??= join(agentDir, ".compile-cache");
}
// ---------------------------------------------------------------------------
// Logger initialization — configure LogTape early so all downstream modules
// emit structured logs instead of raw console.* calls.
// ---------------------------------------------------------------------------
await configureLogger({
sessionId: process.env.SF_SESSION_ID || `cli-${Date.now()}`,
mode:
process.env.SF_AUTONOMOUS === "1" || process.env.NODE_ENV === "production"
? "autonomous"
: "dev",
});
function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
const currentVersion = process.env.SF_VERSION || "0.0.0";
const managedVersion = getNewerManagedResourceVersion(
@ -974,7 +987,7 @@ try {
);
ensureSiftIndexWarmup(
process.cwd(),
loadEffectiveSFPreferences()?.preferences?.codebase,
(loadEffectiveSFPreferences()?.preferences as any)?.codebase,
);
} catch {
/* non-fatal — sift warmup is best-effort */

216
src/logger.ts Normal file
View file

@ -0,0 +1,216 @@
/**
* logger.ts Centralized LogTape configuration for singularity-forge.
*
* Purpose: Provide a single, consistent structured logging surface across all
* SF surfaces (CLI, TUI, web, headless) with automatic PII redaction,
* per-session file rotation, and mode-aware formatting.
*
* Consumer: Every module in src/ and packages/ that needs application logging.
*/
import {
configure,
reset,
getLogger as logtapeGetLogger,
getConsoleSink,
getJsonLinesFormatter,
type LogRecord,
type Sink,
} from "@logtape/logtape";
import { getPrettyFormatter } from "@logtape/pretty";
import { getRotatingFileSink } from "@logtape/file";
import { redactByPattern, redactByField } from "@logtape/redaction";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
export interface LoggerOptions {
/** Session identifier for per-session log directories. */
sessionId?: string;
/** Runtime mode: dev = pretty console, autonomous = JSON + file. */
mode?: "dev" | "autonomous";
/** Override the default log level. */
level?: "debug" | "info" | "warning" | "error" | "fatal";
/** Base directory for log files (defaults to cwd/.sf/logs). */
logDir?: string;
/** Optional custom sink for testing. */
customSink?: (record: LogRecord) => void;
/** Optional category filters for testing. */
filters?: Array<{
category: string[];
lowestLevel: "debug" | "info" | "warning" | "error" | "fatal";
}>;
}
let configured = false;
export function resetLoggerConfig(): void {
configured = false;
try {
reset();
} catch {
/* ignore if not configured */
}
}
const API_KEY_PATTERN = {
pattern: /(\b(?:sk|key)-[\w-]+|\bBearer\s+[\w-]+|\bapi_key=[\w-]+)/g,
replacement: "[REDACTED]",
};
/**
* Build a redacting sink wrapper that applies pattern-based redaction to
* formatted output and field-based redaction to structured properties.
*
* Purpose: Ensure API keys and home directory paths never leak into logs,
* regardless of whether they appear in message strings or property values.
*/
function buildRedactingSink(
downstream: (record: LogRecord) => void,
homeDir: string,
): Sink {
// Field-based redaction for structured properties
const fieldRedacted = redactByField(downstream, {
fieldPatterns: [/^api[_-]?key$/i, /^token$/i, /^secret$/i, /^password$/i],
action: () => "[REDACTED]",
});
// Pattern-based redaction for message strings (includes home path)
const homePathPattern = {
pattern: new RegExp(homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
replacement: "~",
};
// redactByPattern expects a formatter (returns string), not a sink.
// For sinks, we apply pattern redaction via a wrapper that mutates
// the record's message strings before passing to the downstream sink.
const redactedSink: Sink = (record: LogRecord) => {
const mutated: LogRecord = {
...record,
message: record.message.map((m) => {
if (typeof m !== "string") return m;
let s = m;
s = s.replace(API_KEY_PATTERN.pattern, API_KEY_PATTERN.replacement);
s = s.replace(homePathPattern.pattern, homePathPattern.replacement);
return s;
}) as unknown[],
};
fieldRedacted(mutated);
};
return redactedSink;
}
/**
* Configure LogTape sinks and loggers for the current process.
*
* Purpose: One-time setup that selects pretty vs JSON output, enables file
* rotation in autonomous mode, and wraps everything in PII redaction.
*
* Consumer: src/cli.ts early in startup, and test suites.
*/
export async function configureLogger(options: LoggerOptions = {}): Promise<void> {
if (configured) {
return;
}
const mode = options.mode ?? inferMode();
const level = options.level ?? (mode === "dev" ? "debug" : "info");
const logDir = options.logDir ?? join(process.cwd(), ".sf", "logs");
const sessionId = options.sessionId ?? "default";
const homeDir = process.env.HOME || process.env.USERPROFILE || "/home/user";
const sinks: Record<string, Sink> = {};
if (mode === "dev") {
const prettyFormatter = getPrettyFormatter();
const redactingFormatter = redactByPattern(prettyFormatter, [
API_KEY_PATTERN,
{
pattern: new RegExp(
homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
),
replacement: "~",
},
]);
sinks.pretty = getConsoleSink({ formatter: redactingFormatter });
} else {
const jsonFormatter = getJsonLinesFormatter();
const redactingJsonFormatter = redactByPattern(jsonFormatter, [
API_KEY_PATTERN,
{
pattern: new RegExp(
homeDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
"g",
),
replacement: "~",
},
]);
sinks.console = getConsoleSink({ formatter: redactingJsonFormatter });
const sessionLogDir = join(logDir, sessionId);
try {
mkdirSync(sessionLogDir, { recursive: true });
} catch {
/* non-fatal */
}
sinks.file = getRotatingFileSink(join(sessionLogDir, "sf.log"), {
maxSize: 10 * 1024 * 1024,
maxFiles: 5,
formatter: redactingJsonFormatter,
});
}
if (options.customSink) {
sinks.custom = buildRedactingSink(options.customSink, homeDir);
}
const loggers = [
{
category: ["sf"],
lowestLevel: level,
sinks:
mode === "dev"
? ["pretty", "custom"].filter((s) => s in sinks)
: ["console", "file", "custom"].filter((s) => s in sinks),
},
];
if (options.filters) {
for (const f of options.filters) {
loggers.push({
category: f.category,
lowestLevel: f.lowestLevel,
sinks: [],
});
}
}
await configure({
sinks,
loggers,
});
configured = true;
}
/**
* Get a LogTape logger for the given dot-separated category.
*
* Purpose: Provide a typed, category-hierarchical logger so modules can
* declare their logging domain and inherit filters from parent categories.
*
* Consumer: Every migrated module calls `const log = getLogger("sf.core.env")`.
*/
export function getLogger(category: string): ReturnType<typeof logtapeGetLogger> {
return logtapeGetLogger(category.split("."));
}
function inferMode(): "dev" | "autonomous" {
if (process.env.SF_AUTONOMOUS === "1" || process.env.NODE_ENV === "production") {
return "autonomous";
}
return "dev";
}

218
src/tests/logger.test.ts Normal file
View file

@ -0,0 +1,218 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { configureLogger, getLogger, resetLoggerConfig } from "../logger.js";
describe("logger", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-logger-test-"));
resetLoggerConfig();
});
afterEach(() => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
describe("API exports", () => {
it("getLogger returns object with info/warn/error/debug methods", () => {
const log = getLogger("sf.core");
expect(typeof log.info).toBe("function");
expect(typeof log.warn).toBe("function");
expect(typeof log.error).toBe("function");
expect(typeof log.debug).toBe("function");
});
});
describe("category hierarchy", () => {
it("filtering sf.uok suppresses sf.uok.loop-adapter", async () => {
const logs: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
logs.push(record.message[0] as string);
},
filters: [{ category: ["sf", "uok"], lowestLevel: "fatal" }],
});
const parent = getLogger("sf.uok");
const child = getLogger("sf.uok.loop-adapter");
parent.info("parent-msg");
child.info("child-msg");
expect(logs).not.toContain("parent-msg");
expect(logs).not.toContain("child-msg");
});
});
describe("PII redaction", () => {
it("API keys (sk-*) are redacted to [REDACTED]", async () => {
const logs: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
logs.push(JSON.stringify(record.message));
},
});
const log = getLogger("sf.core");
log.info("key is sk-ant-abc123def456");
// The customSink receives record.message where pattern redaction
// has already been applied (via buildRedactingSink).
expect(logs.some((l) => l.includes("[REDACTED]") && !l.includes("sk-ant-"))).toBe(true);
});
it("home directory paths are redacted to ~", async () => {
const logs: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
logs.push(JSON.stringify(record.message));
},
});
const log = getLogger("sf.core");
const home = process.env.HOME || "/home/user";
log.info(`path is ${home}/projects/foo`);
// The customSink receives record.message where pattern redaction
// has already been applied (via buildRedactingSink).
expect(logs.some((l) => l.includes("~/projects/foo"))).toBe(true);
expect(logs.some((l) => l.includes(home) && !l.includes("~"))).toBe(false);
});
});
describe("log level filtering", () => {
it("warn level suppresses info messages", async () => {
const logs: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
logs.push(record.message[0] as string);
},
level: "warning",
});
const log = getLogger("sf.core");
log.info("info-msg");
log.warn("warn-msg");
log.error("error-msg");
expect(logs).not.toContain("info-msg");
expect(logs).toContain("warn-msg");
expect(logs).toContain("error-msg");
});
});
describe("dev mode", () => {
it("output is formatted text not JSON", async () => {
const lines: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
lines.push(record.message[0] as string);
},
});
const log = getLogger("sf.core");
log.info("hello dev");
expect(lines.length).toBeGreaterThan(0);
});
it("default level is debug so all messages appear", async () => {
const logs: string[] = [];
await configureLogger({
mode: "dev",
logDir: tmpDir,
customSink: (record) => {
logs.push(record.message[0] as string);
},
});
const log = getLogger("sf.core");
log.debug("debug-msg");
log.info("info-msg");
log.warn("warn-msg");
log.error("error-msg");
expect(logs).toContain("debug-msg");
expect(logs).toContain("info-msg");
expect(logs).toContain("warn-msg");
expect(logs).toContain("error-msg");
});
});
describe("autonomous mode", () => {
it("output is valid JSON with timestamp/level/category/message fields", async () => {
const lines: string[] = [];
await configureLogger({
mode: "autonomous",
logDir: tmpDir,
customSink: (record) => {
lines.push(JSON.stringify({
ts: record.timestamp,
level: record.level,
category: record.category,
message: record.message,
}));
},
});
const log = getLogger("sf.autonomous");
log.info("hello autonomous");
expect(lines.length).toBeGreaterThan(0);
const parsed = JSON.parse(lines[0]);
expect(parsed.ts).toBeDefined();
expect(parsed.level).toBe("info");
expect(parsed.category).toEqual(["sf", "autonomous"]);
expect(parsed.message).toContain("hello autonomous");
});
it("per-session log directory created", async () => {
const sessionId = "test-session-123";
const logDir = join(tmpDir, ".sf", "logs");
await configureLogger({
mode: "autonomous",
logDir,
sessionId,
});
const log = getLogger("sf.core");
log.info("create dir");
const sessionDir = join(logDir, sessionId);
expect(existsSync(sessionDir)).toBe(true);
});
it("log file contains valid JSONL", async () => {
const sessionId = "jsonl-session-456";
const logDir = join(tmpDir, ".sf", "logs");
await configureLogger({
mode: "autonomous",
logDir,
sessionId,
});
const log = getLogger("sf.core");
log.info("line one");
log.warn("line two");
// Flush file sinks via LogTape dispose
const { dispose } = await import("@logtape/logtape");
await dispose();
await new Promise((r) => setTimeout(r, 200));
const sessionDir = join(logDir, sessionId);
const allFiles = readdirSync(sessionDir);
const files = allFiles.filter((f) => f.endsWith(".log"));
expect(files.length).toBeGreaterThan(0);
const content = readFileSync(join(sessionDir, files[0]), "utf-8").trim();
const lines = content.split("\n").filter((l) => l.length > 0);
expect(lines.length).toBeGreaterThanOrEqual(2);
for (const line of lines) {
const parsed = JSON.parse(line);
expect(parsed["@timestamp"]).toBeDefined();
expect(parsed.level).toBeDefined();
expect(parsed.logger).toBeDefined();
expect(parsed.message).toBeDefined();
}
});
});
});