feat(FN-2327): standardize dashboard runtime logging pipeline

- Add a shared runtime logger contract and dashboard runtime logger implementation for structured diagnostics
- Route dashboard CLI/runtime logs through the TUI sink and replace ad-hoc console diagnostics in server paths
- Update CLI and dashboard tests to assert structured runtime logging behavior across sync and error flows
- Document the structured logging architecture updates and include a changeset for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-23 13:48:45 -07:00
committed by gsxdsm
parent 18b2c827a0
commit c01892dbd2
14 changed files with 436 additions and 113 deletions

View File

@@ -2615,3 +2615,61 @@ describe("runDashboard — merge stream sink routing", () => {
consoleLogSpy.mockRestore();
});
});
describe("runDashboard runtime logger wiring", () => {
it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => {
const { createServer } = await import("@fusion/dashboard");
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runDashboard(0, { open: false, dev: true });
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)!;
const serverOpts = createServerCall[1] as { runtimeLogger?: { info: (message: string, context?: Record<string, unknown>) => void } };
expect(serverOpts.runtimeLogger).toBeDefined();
serverOpts.runtimeLogger?.info("runtime diagnostic", { source: "test" });
expect(consoleLogSpy).toHaveBeenCalledWith(
'[dashboard] runtime diagnostic {"source":"test"}',
);
consoleLogSpy.mockRestore();
});
it("routes runtime logger output through DashboardLogSink in TTY mode", async () => {
const { createServer } = await import("@fusion/dashboard");
const { DashboardLogSink, DashboardTUI } = await import("./dashboard-tui.js");
const originalStdoutIsTTY = process.stdout.isTTY;
const originalStdinIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
const tuiStartSpy = vi.spyOn(DashboardTUI.prototype, "start").mockResolvedValue(undefined);
const tuiStopSpy = vi.spyOn(DashboardTUI.prototype, "stop").mockResolvedValue(undefined);
const tuiLogSpy = vi.spyOn(DashboardTUI.prototype, "log").mockImplementation(() => {});
const captureConsoleSpy = vi.spyOn(DashboardLogSink.prototype, "captureConsole").mockImplementation(() => {});
try {
await runDashboard(0, { open: false, dev: true });
expect(captureConsoleSpy).toHaveBeenCalledTimes(1);
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls.at(-1)!;
const serverOpts = createServerCall[1] as { runtimeLogger?: { info: (message: string, context?: Record<string, unknown>) => void } };
expect(serverOpts.runtimeLogger).toBeDefined();
serverOpts.runtimeLogger?.info("tty runtime diagnostic", { source: "test" });
expect(tuiLogSpy).toHaveBeenCalledWith('tty runtime diagnostic {"source":"test"}', "dashboard");
expect(tuiStartSpy).toHaveBeenCalled();
} finally {
Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true });
Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true });
tuiStartSpy.mockRestore();
tuiStopSpy.mockRestore();
tuiLogSpy.mockRestore();
captureConsoleSpy.mockRestore();
}
});
});

View File

@@ -2,7 +2,14 @@ import type { AddressInfo } from "node:net";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent } from "@fusion/core";
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
import {
createServer,
GitHubClient,
createSkillsAdapter,
getProjectSettingsPath,
loadTlsCredentialsFromEnv,
type RuntimeLogger,
} from "@fusion/dashboard";
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager, PeerExchangeService } from "@fusion/engine";
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
@@ -28,6 +35,36 @@ let diagnosticStoreListenerCheck: (() => Record<string, number>) | null = null;
const STREAM_LOG_FLUSH_IDLE_MS = 100;
function formatRuntimeContext(context: Record<string, unknown> | undefined): string {
if (context === undefined) {
return "";
}
try {
return ` ${JSON.stringify(context)}`;
} catch {
return ` ${String(context)}`;
}
}
function createDashboardRuntimeLogger(logSink: DashboardLogSink, scope: string): RuntimeLogger {
return {
scope,
info(message, context) {
logSink.log(`${message}${formatRuntimeContext(context)}`, scope);
},
warn(message, context) {
logSink.warn(`${message}${formatRuntimeContext(context)}`, scope);
},
error(message, context) {
logSink.error(`${message}${formatRuntimeContext(context)}`, scope);
},
child(childScope) {
return createDashboardRuntimeLogger(logSink, `${scope}:${childScope}`);
},
};
}
export class StreamedLogBuffer {
private pending = "";
private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -123,7 +160,7 @@ function formatUptime(ms: number): string {
* @param startTime - Process start timestamp
* @param dbHealthCheck - Optional function to check database health
*/
function logDiagnostics(prefix: string, startTime: number, dbHealthCheck?: () => boolean): void {
function logDiagnostics(logger: RuntimeLogger, prefix: string, startTime: number, dbHealthCheck?: () => boolean): void {
const mem = process.memoryUsage();
const uptime = Date.now() - startTime;
@@ -168,7 +205,7 @@ function logDiagnostics(prefix: string, startTime: number, dbHealthCheck?: () =>
`external=${formatBytes(mem.external)} arrayBuffers=${formatBytes(mem.arrayBuffers)} ` +
`handles=${handleCount} requests=${requestCount} db=${dbHealth}${listenerInfo}`;
console.log(logLine);
logger.info(logLine);
}
/**
@@ -176,7 +213,7 @@ function logDiagnostics(prefix: string, startTime: number, dbHealthCheck?: () =>
* Logs memory usage, handle counts, and uptime at startup and every 30 minutes.
* Also logs beforeExit and exit events for shutdown analysis.
*/
function ensureProcessDiagnostics(): void {
function ensureProcessDiagnostics(logger: RuntimeLogger): void {
if (processDiagnosticsRegistered) {
return;
}
@@ -185,11 +222,11 @@ function ensureProcessDiagnostics(): void {
diagnosticStartTime = Date.now();
// Log initial diagnostics at startup (before store is created)
logDiagnostics("dashboard", diagnosticStartTime);
logDiagnostics(logger, "dashboard", diagnosticStartTime);
// Register periodic diagnostics every 30 minutes
diagnosticIntervalHandle = setInterval(() => {
logDiagnostics("dashboard", diagnosticStartTime, diagnosticDbHealthCheck ?? undefined);
logDiagnostics(logger, "dashboard", diagnosticStartTime, diagnosticDbHealthCheck ?? undefined);
}, DIAGNOSTIC_INTERVAL_MS);
diagnosticIntervalHandle.unref?.(); // Don't prevent process exit
@@ -206,24 +243,24 @@ function ensureProcessDiagnostics(): void {
} catch {
// Ignore
}
console.log(`[dashboard] beforeExit code=${code} uptime=${formatUptime(uptime)} handles=${handleCount} requests=${requestCount}`);
logger.info(`[dashboard] beforeExit code=${code} uptime=${formatUptime(uptime)} handles=${handleCount} requests=${requestCount}`);
});
// Log exit event with exit code and uptime
process.on("exit", (code: number) => {
const uptime = Date.now() - diagnosticStartTime;
console.log(`[dashboard] exit code=${code} uptime=${formatUptime(uptime)}`);
logger.info(`[dashboard] exit code=${code} uptime=${formatUptime(uptime)}`);
});
// Log uncaught exceptions
process.on("uncaughtExceptionMonitor", (error: Error) => {
console.error(`[dashboard] uncaught exception pid=${process.pid}: ${error.stack || error.message}`);
logger.error(`[dashboard] uncaught exception pid=${process.pid}: ${error.stack || error.message}`);
});
// Log unhandled rejections
process.on("unhandledRejection", (reason: unknown) => {
const message = reason instanceof Error ? reason.stack || reason.message : String(reason);
console.error(`[dashboard] unhandled rejection pid=${process.pid}: ${message}`);
logger.error(`[dashboard] unhandled rejection pid=${process.pid}: ${message}`);
});
}
@@ -285,7 +322,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
?? process.env.FUSION_DASHBOARD_TOKEN
?? process.env.FUSION_DAEMON_TOKEN
?? `fn_${randomBytes(16).toString("hex")}`;
ensureProcessDiagnostics();
// Single sink/logger pair for all dashboard command diagnostics.
// In TTY mode this routes to DashboardTUI; in non-TTY mode it falls back to console.*.
const logSink = new DashboardLogSink();
const runtimeLogger = createDashboardRuntimeLogger(logSink, "dashboard");
// Handle interactive port selection
let selectedPort = port;
@@ -320,9 +361,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
let store: TaskStore | undefined;
let agentStore: AgentStore | undefined;
// Create a log sink that routes to TUI in TTY mode, or console otherwise
const logSink = new DashboardLogSink();
if (isTTY) {
tui = new DashboardTUI();
// Set up callbacks for utility actions
@@ -394,6 +432,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
logSink.captureConsole();
}
// Register long-running process diagnostics after TTY sink wiring so
// startup/runtime lines flow into the TUI log buffer when interactive.
ensureProcessDiagnostics(runtimeLogger);
store = new TaskStore(cwd);
await store.init();
await store.watch();
@@ -925,6 +967,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
runtimeLogger,
});
const shutdown = async (signal: NodeJS.Signals) => {
@@ -1112,6 +1155,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
noAuth: opts.noAuth,
runtimeLogger,
});
}