Backend Logging System #
Overview #
The backend adapter communicates with the Fyrox frontend via JSON-lines over stdin/stdout. This creates a critical constraint: stdout is reserved exclusively for IPC messages. Any non-JSON output to stdout will break the protocol and cause parse errors on the frontend.
This document defines a logging system that prevents accidental stdout pollution while providing structured, debuggable logs.
Problem Statement #
The stdout Trap #
// THIS IS DANGEROUS - breaks IPC protocol!
console.log('[Debug] Processing message...');
// Frontend tries to parse this as JSON:
// Error: expected value at line 1 column 2
Common sources of stdout pollution:
console.log()calls (should beconsole.error())- Libraries that log to stdout by default
- Uncaught exceptions printed to stdout
- Debug statements left in production code
Requirements #
- Hard stdout protection: Make it impossible to accidentally write to stdout
- Structured logging: JSON-formatted logs with levels, timestamps, context
- Log levels: trace, debug, info, warn, error
- Performance: Minimal overhead, async-safe
- Testability: Logs can be captured and asserted in tests
- Source tracking: Know which module emitted each log
Design #
Architecture #
┌─────────────────────────────────────────────────────────┐
│ Backend Process │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ IPC Module │────►│ stdout │ (JSON-lines) │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Logger │────►│ stderr │ (structured) │
│ └──────────────┘ └──────────────┘ │
│ ▲ │
│ │ │
│ ┌──────┴───────┬───────────────┬──────────────┐ │
│ │ sdk-adapter │ oauth-manager │ proxy │ │
│ └──────────────┴───────────────┴──────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
Logger API #
// packages/logger/src/index.ts
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
export interface LogContext {
module?: string;
[key: string]: unknown;
}
export interface Logger {
trace(message: string, context?: LogContext): void;
debug(message: string, context?: LogContext): void;
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, context?: LogContext): void;
// Create a child logger with preset context
child(context: LogContext): Logger;
}
// Factory function
export function createLogger(module: string): Logger;
// Global configuration
export function configureLogging(options: {
level?: LogLevel;
format?: 'json' | 'pretty';
}): void;
Usage #
// In sdk-adapter.ts
import { createLogger } from '@tiny-workshop/logger';
const log = createLogger('sdk-adapter');
log.info('Initializing SDK adapter');
log.debug('OAuth proxy started', { port: 12345 });
log.error('Failed to exchange code', { error: err.message });
// Child loggers for subsystems
const queryLog = log.child({ subsystem: 'query' });
queryLog.info('Starting query', { sessionId: '123' });
Output Format #
JSON Format (default for production) #
{"ts":"2025-01-04T12:00:00.000Z","level":"info","module":"sdk-adapter","msg":"OAuth proxy started","port":12345}
{"ts":"2025-01-04T12:00:01.000Z","level":"error","module":"proxy","msg":"Request failed","error":"ConnectionRefused","url":"https://api.anthropic.com/v1/messages"}
Pretty Format (for development) #
12:00:00.000 INFO [sdk-adapter] OAuth proxy started port=12345
12:00:01.000 ERROR [proxy] Request failed error=ConnectionRefused url=https://api.anthropic.com/v1/messages
Implementation #
// packages/logger/src/index.ts
const LOG_LEVELS: Record<LogLevel, number> = {
trace: 0,
debug: 1,
info: 2,
warn: 3,
error: 4,
};
let currentLevel: LogLevel = 'info';
let format: 'json' | 'pretty' = 'json';
export function configureLogging(options: {
level?: LogLevel;
format?: 'json' | 'pretty';
}): void {
if (options.level) currentLevel = options.level;
if (options.format) format = options.format;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function formatMessage(
level: LogLevel,
module: string,
message: string,
context?: LogContext
): string {
const ts = new Date().toISOString();
if (format === 'json') {
return JSON.stringify({
ts,
level,
module,
msg: message,
...context,
});
}
// Pretty format
const time = ts.split('T')[1].replace('Z', '');
const levelStr = level.toUpperCase().padEnd(5);
const contextStr = context
? ' ' + Object.entries(context)
.filter(([k]) => k !== 'module')
.map(([k, v]) => `${k}=${v}`)
.join(' ')
: '';
return `${time} ${levelStr} [${module}] ${message}${contextStr}`;
}
function write(
level: LogLevel,
module: string,
message: string,
context?: LogContext
): void {
if (!shouldLog(level)) return;
const formatted = formatMessage(level, module, message, context);
// CRITICAL: Always write to stderr, never stdout!
console.error(formatted);
}
class LoggerImpl implements Logger {
constructor(
private module: string,
private baseContext: LogContext = {}
) {}
trace(message: string, context?: LogContext): void {
write('trace', this.module, message, { ...this.baseContext, ...context });
}
debug(message: string, context?: LogContext): void {
write('debug', this.module, message, { ...this.baseContext, ...context });
}
info(message: string, context?: LogContext): void {
write('info', this.module, message, { ...this.baseContext, ...context });
}
warn(message: string, context?: LogContext): void {
write('warn', this.module, message, { ...this.baseContext, ...context });
}
error(message: string, context?: LogContext): void {
write('error', this.module, message, { ...this.baseContext, ...context });
}
child(context: LogContext): Logger {
return new LoggerImpl(this.module, { ...this.baseContext, ...context });
}
}
export function createLogger(module: string): Logger {
return new LoggerImpl(module);
}
Stdout Protection #
To prevent accidental stdout usage, we can optionally patch console.log:
// packages/logger/src/protect-stdout.ts
const originalLog = console.log;
let stdoutProtectionEnabled = false;
export function enableStdoutProtection(): void {
if (stdoutProtectionEnabled) return;
console.log = (...args: unknown[]) => {
// In development, warn loudly
if (process.env.NODE_ENV !== 'production') {
console.error(
'[STDOUT PROTECTION] console.log() called! Use logger instead:',
...args
);
console.error(new Error().stack);
}
// In production, silently redirect to stderr
console.error('[REDIRECTED]', ...args);
};
stdoutProtectionEnabled = true;
}
export function disableStdoutProtection(): void {
console.log = originalLog;
stdoutProtectionEnabled = false;
}
Initialization #
// packages/adapter/src/index.ts
import { configureLogging, createLogger } from '@tiny-workshop/logger';
import { enableStdoutProtection } from '@tiny-workshop/logger/protect-stdout';
// First thing: protect stdout
enableStdoutProtection();
// Configure logging based on environment
configureLogging({
level: process.env.LOG_LEVEL as LogLevel ?? 'info',
format: process.env.NODE_ENV === 'development' ? 'pretty' : 'json',
});
const log = createLogger('main');
log.info('Backend adapter starting');
Package Structure #
backend/
└── packages/
└── logger/
├── package.json
├── src/
│ ├── index.ts # Main logger API
│ ├── protect-stdout.ts # Stdout protection
│ └── types.ts # Type definitions
└── tests/
└── logger.test.ts
package.json #
{
"name": "@tiny-workshop/logger",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./protect-stdout": "./src/protect-stdout.ts"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
Integration with Existing Code #
Migration Path #
- Create logger package with the API above
- Add stdout protection to adapter entry point
- Replace console.error calls with logger calls:
// Before
console.error('[Auth] OAuth proxy started on port', port);
// After
import { createLogger } from '@tiny-workshop/logger';
const log = createLogger('auth');
log.info('OAuth proxy started', { port });
- Update proxy.ts to use logger
- Update sdk-adapter.ts to use logger
- Update ipc.ts to use logger (but keep emit() using stdout!)
IPC Module Exception #
The ipc.ts module is the ONLY code allowed to write to stdout:
// packages/adapter/src/ipc.ts
import { createLogger } from '@tiny-workshop/logger';
const log = createLogger('ipc');
export function emit(message: BackendMessage): void {
// This is the ONLY place stdout is used
const json = JSON.stringify(message);
process.stdout.write(json + '\n');
// Log what we emitted (to stderr via logger)
log.debug('Emitted message', { type: message.type });
}
Testing #
// packages/logger/tests/logger.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { createLogger, configureLogging } from '../src';
describe('Logger', () => {
let stderrOutput: string[] = [];
const originalStderr = process.stderr.write;
beforeEach(() => {
stderrOutput = [];
process.stderr.write = (chunk: string) => {
stderrOutput.push(chunk.toString());
return true;
};
});
afterEach(() => {
process.stderr.write = originalStderr;
});
it('writes to stderr, not stdout', () => {
const log = createLogger('test');
log.info('Hello');
expect(stderrOutput.length).toBe(1);
expect(stderrOutput[0]).toContain('Hello');
});
it('respects log levels', () => {
configureLogging({ level: 'warn' });
const log = createLogger('test');
log.debug('Should not appear');
log.warn('Should appear');
expect(stderrOutput.length).toBe(1);
expect(stderrOutput[0]).toContain('Should appear');
});
it('includes context in output', () => {
configureLogging({ format: 'json' });
const log = createLogger('test');
log.info('Test', { userId: 123 });
const parsed = JSON.parse(stderrOutput[0]);
expect(parsed.userId).toBe(123);
});
});
Environment Variables #
| Variable | Values | Default | Description |
|---|---|---|---|
LOG_LEVEL |
trace, debug, info, warn, error | info | Minimum log level |
LOG_FORMAT |
json, pretty | json | Output format |
PROTECT_STDOUT |
true, false | true | Enable stdout protection |
Future Enhancements #
- File logging: Write logs to file in addition to stderr
- Log rotation: Rotate log files by size/time
- Remote logging: Send logs to external service
- Correlation IDs: Track requests across async boundaries
- Performance metrics: Log timing information automatically
- Sampling: Reduce log volume in high-traffic scenarios
Open Questions #
-
Should we use an existing logging library? (e.g., pino, winston)
- Pro: Battle-tested, feature-rich
- Con: May write to stdout by default, needs configuration
-
Should logs include stack traces for errors?
- Pro: Easier debugging
- Con: Verbose, may leak sensitive info
-
How to handle logs from dependencies?
- Some npm packages log to stdout/stderr directly
- May need to patch or configure them
Summary #
This logging system provides:
- Safety: stdout is protected, all debug output goes to stderr
- Structure: JSON-formatted logs with consistent schema
- Flexibility: Log levels, formats, child loggers
- Debuggability: Module names, timestamps, context data
- Testability: Logs can be captured and asserted
The key invariant: stdout is sacred - only IPC JSON messages go there.