Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"linter": {
"rules": {
"recommended": true,
"suspicious": { "noExplicitAny": "error" },
"suspicious": { "noExplicitAny": "error", "noConsole": "warn" },
"performance": {
"noAccumulatingSpread": "error",
"recommended": true
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"build:types": "bun run --filter @fiberplane/mcp-gateway build:types",
"test": "bun test",
"typecheck": "bun run --filter '*' typecheck",
"lint": "biome check .",
"format": "biome format --write .",
"lint": "biome ci .",
"format": "biome check --write .",
"changeset": "changeset",
"release": "changeset publish"
},
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
"sideEffects": false,
"scripts": {
"dev": "bun build ./src/run.ts --outdir dist --root src --sourcemap=inline --target node --format esm --watch",
"dev:server": "LOG_LEVEL=debug bun run src/server --watch",
"build": "bun run scripts/build.ts",
"test": "bun test",
"typecheck": "tsc --noEmit",
"lint": "biome check --write .",
"format": "biome format --write .",
"lint": "biome ci .",
"format": "biome check --write .",
"prepublishOnly": "bun run build"
},
"exports": {
Expand Down
40 changes: 27 additions & 13 deletions packages/mcp-gateway/src/capture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { constants } from "node:fs";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { logger } from "./logger.js";
import type {
CaptureRecord,
ClientInfo,
Expand Down Expand Up @@ -76,7 +77,7 @@ export function createRequestCaptureRecord(
// Validate the record
const result = captureRecordSchema.safeParse(record);
if (!result.success) {
console.warn("Invalid capture record:", result.error);
logger.warn("Invalid capture record", { error: result.error });
throw new Error("Failed to create valid capture record");
}

Expand Down Expand Up @@ -121,7 +122,7 @@ export function createResponseCaptureRecord(
// Validate the record
const result = captureRecordSchema.safeParse(record);
if (!result.success) {
console.warn("Invalid capture record:", result.error);
logger.warn("Invalid capture record", { error: result.error });
throw new Error("Failed to create valid capture record");
}

Expand All @@ -132,17 +133,19 @@ export async function appendCapture(
storageDir: string,
record: CaptureRecord,
): Promise<string> {
try {
// Ensure server capture directory exists
await ensureServerCaptureDir(storageDir, record.metadata.serverName);
// Generate filename (one per session)
let filePath: string = "unknown";

// Generate filename (one per session)
try {
const filename = generateCaptureFilename(
record.metadata.serverName,
record.metadata.sessionId,
);

const filePath = join(storageDir, record.metadata.serverName, filename);
filePath = join(storageDir, record.metadata.serverName, filename);

// Ensure server capture directory exists
await ensureServerCaptureDir(storageDir, record.metadata.serverName);

// Append JSONL record to file
const jsonLine = `${JSON.stringify(record)}\n`;
Expand All @@ -159,8 +162,19 @@ export async function appendCapture(
await writeFile(filePath, existingContent + jsonLine, "utf8");
return filename;
} catch (error) {
console.error("Failed to append capture record:", error);
throw new Error(`Capture storage failed: ${error}`);
logger.error("Failed to append capture record", {
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: String(error),
filePath,
});
throw new Error(
`Capture storage failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

Expand Down Expand Up @@ -233,7 +247,7 @@ export function createSSEEventCaptureRecord(
// Validate the record
const result = captureRecordSchema.safeParse(record);
if (!result.success) {
console.warn("Invalid SSE capture record:", result.error);
logger.warn("Invalid SSE capture record", { error: result.error });
throw new Error("Failed to create valid SSE capture record");
}

Expand Down Expand Up @@ -315,7 +329,7 @@ export function createSSEJsonRpcCaptureRecord(
// Validate the record
const result = captureRecordSchema.safeParse(record);
if (!result.success) {
console.warn("Invalid SSE JSON-RPC capture record:", result.error);
logger.warn("Invalid SSE JSON-RPC capture record", { error: result.error });
throw new Error("Failed to create valid SSE JSON-RPC capture record");
}

Expand All @@ -341,7 +355,7 @@ export async function captureSSEEvent(
);
await appendCapture(storageDir, record);
} catch (error) {
console.error("Failed to capture SSE event:", error);
logger.error("Failed to capture SSE event", { error: String(error) });
// Don't throw - SSE capture failures shouldn't break streaming
}
}
Expand All @@ -366,7 +380,7 @@ export async function captureSSEJsonRpc(
await appendCapture(storageDir, record);
return record;
} catch (error) {
console.error("Failed to capture SSE JSON-RPC:", error);
logger.error("Failed to capture SSE JSON-RPC", { error: String(error) });
// Don't throw - SSE capture failures shouldn't break streaming
return null;
}
Expand Down
208 changes: 208 additions & 0 deletions packages/mcp-gateway/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { appendFile, readdir, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { ensureStorageDir } from "./storage.js";

type LogLevel = "debug" | "info" | "warn" | "error";

interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: Record<string, unknown>;
}

class Logger {
private storageDir: string | null = null;
private currentDate: string | null = null;
private logDir: string | null = null;
private minLevel: LogLevel = "info"; // Default: skip debug logs

/**
* Initialize the logger with the storage directory
*/
async initialize(storageDir: string): Promise<void> {
this.storageDir = storageDir;
this.logDir = join(storageDir, "logs");
this.currentDate = this.getDateString();

// Set minimum log level from environment variable
const envLevel = process.env.LOG_LEVEL?.toLowerCase();
if (
envLevel === "debug" ||
envLevel === "info" ||
envLevel === "warn" ||
envLevel === "error"
) {
this.minLevel = envLevel;
}

// Ensure log directory exists
await ensureStorageDir(this.logDir);

// Clean up old logs (non-blocking)
this.cleanupOldLogs().catch((error) => {
// Silently fail - don't block initialization
// biome-ignore lint/suspicious/noConsole: actually want to print to console
console.error("Failed to cleanup old logs:", error);
});
}

/**
* Check if a log level should be written based on minimum level
*/
private shouldLog(level: LogLevel): boolean {
const levels: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
return levels[level] >= levels[this.minLevel];
}

/**
* Get current date string in YYYY-MM-DD format
*/
private getDateString(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

/**
* Get the current log file path
*/
private getLogFilePath(): string {
if (!this.logDir) {
throw new Error("Logger not initialized. Call initialize() first.");
}

const currentDate = this.getDateString();

// Check if we need to rotate to a new day
if (this.currentDate !== currentDate) {
this.currentDate = currentDate;
}

return join(this.logDir, `gateway-${currentDate}.log`);
}

/**
* Write a log entry to file
*/
private async writeLog(
level: LogLevel,
message: string,
context?: Record<string, unknown>,
): Promise<void> {
if (!this.storageDir) {
// Logger not initialized, silently skip
return;
}

// Check if this log level should be written
if (!this.shouldLog(level)) {
return;
}

try {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
...(context && Object.keys(context).length > 0 ? { context } : {}),
};

const logLine = `${JSON.stringify(entry)}\n`;
const logFile = this.getLogFilePath();

await appendFile(logFile, logLine, "utf-8");
} catch (error) {
// Silently fail - don't throw errors from logger
// HACK - Don't log errors in test environment (this was a quickfix, sorry)
if (!this.isTestEnvironment()) {
// biome-ignore lint/suspicious/noConsole: actually want to print to console
console.error("Failed to write log:", error);
}
}
}

private isTestEnvironment(): boolean {
return (
process.env.NODE_ENV === "test" ||
process.env.BTEST === "1" || // Bun sets this during tests
(typeof Bun !== "undefined" && Bun.env.TEST === "true")
);
}

/**
* Log a debug message
*/
debug(message: string, context?: Record<string, unknown>): void {
this.writeLog("debug", message, context).catch(() => {
// Ignore errors
});
}

/**
* Log an info message
*/
info(message: string, context?: Record<string, unknown>): void {
this.writeLog("info", message, context).catch(() => {
// Ignore errors
});
}

/**
* Log a warning message
*/
warn(message: string, context?: Record<string, unknown>): void {
this.writeLog("warn", message, context).catch(() => {
// Ignore errors
});
}

/**
* Log an error message
*/
error(message: string, context?: Record<string, unknown>): void {
this.writeLog("error", message, context).catch(() => {
// Ignore errors
});
}

/**
* Clean up log files older than 30 days
*/
private async cleanupOldLogs(): Promise<void> {
if (!this.logDir) {
return;
}

try {
const files = await readdir(this.logDir);
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;

for (const file of files) {
if (!file.startsWith("gateway-") || !file.endsWith(".log")) {
continue;
}

const filePath = join(this.logDir, file);
const stats = await stat(filePath);

if (stats.mtimeMs < thirtyDaysAgo) {
await unlink(filePath);
}
}
} catch (_error) {
// Silently fail - cleanup is not critical
}
}
}

// Export singleton instance
export const logger = new Logger();
Loading