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
1 change: 1 addition & 0 deletions packages/desktop/src/main/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ let debugLogger: DebugLogger = {
log: (input) => record(input.level, input),
getRecentEvents: () => [],
currentFilePath: () => '',
flush: () => Promise.resolve(),
};

export function initDebugLogger(debugDir: string): DebugLogger {
Expand Down
58 changes: 50 additions & 8 deletions packages/desktop/src/main/debug/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { createWriteStream, existsSync, mkdirSync, openSync, type WriteStream } from 'node:fs';
import { join } from 'node:path';
import type { DebugDomain, DebugEvent, DebugLevel } from '@clawwork/shared';
import { sanitizeForLog } from '@clawwork/shared';
Expand Down Expand Up @@ -29,6 +29,7 @@ export interface DebugLogger {
log: (input: LogEventInput & { level: DebugLevel }) => DebugEvent;
getRecentEvents: (filter?: DebugLogFilter) => DebugEvent[];
currentFilePath: () => string;
flush: () => Promise<void>;
}

export interface LogEventInput {
Expand All @@ -55,8 +56,38 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge
const maxEvents = options.maxEvents ?? 1000;
const writeConsole = options.console ?? true;
const recentEvents: DebugEvent[] = [];
const debugDir = options.debugDir;

ensureDir(options.debugDir);
// Write stream state — cached fd avoids open/write/close per event
let writeStream: WriteStream | null = null;
let currentLogDate = '';

ensureDir(debugDir);

function currentFilePath(): string {
const day = new Date().toISOString().slice(0, 10);
return join(debugDir, `debug-${day}.ndjson`);
}

function ensureStream(): void {
const today = new Date().toISOString().slice(0, 10);
if (writeStream && currentLogDate === today) return;

// Close previous day's stream
if (writeStream) {
writeStream.end();
writeStream = null;
}

ensureDir(debugDir);
currentLogDate = today;
const filePath = currentFilePath();
// Open fd synchronously so the file exists immediately and is append-only
const fd = openSync(filePath, 'a');
writeStream = createWriteStream(filePath, { fd, autoClose: true }).on('error', (err) => {
console.error('[debug] logger stream error:', err);
});
}

function log(input: LogEventInput & { level: DebugLevel }): DebugEvent {
const event: DebugEvent = sanitizeForLog({
Expand All @@ -69,7 +100,9 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge
recentEvents.splice(0, recentEvents.length - maxEvents);
}

appendFileSync(currentFilePath(), `${JSON.stringify(event)}\n`, 'utf8');
// Async write via persistent stream — no more blocking the event loop
ensureStream();
writeStream!.write(`${JSON.stringify(event)}\n`, 'utf8');

if (writeConsole) {
const line = `[${event.level}] [${event.domain}] ${event.event}`;
Expand All @@ -82,6 +115,19 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge
return event;
}

async function flush(): Promise<void> {
if (!writeStream || writeStream.closed || writeStream.destroyed) return;
// Write an empty chunk to serve as a flush marker.
// Writable maintains ordering — this callback fires only after
// all previously enqueued data has been written to the OS.
return new Promise<void>((resolve, reject) => {
writeStream!.write(Buffer.alloc(0), (err) => {
if (err) reject(err);
else resolve();
});
});
}

return {
debug: (input) => log({ ...input, level: 'debug' }),
info: (input) => log({ ...input, level: 'info' }),
Expand All @@ -90,12 +136,8 @@ export function createDebugLogger(options: CreateDebugLoggerOptions): DebugLogge
log,
getRecentEvents: (filter) => filterEvents(recentEvents, filter),
currentFilePath,
flush,
};

function currentFilePath(): string {
const day = new Date().toISOString().slice(0, 10);
return join(options.debugDir, `debug-${day}.ndjson`);
}
}

function filterEvents(events: DebugEvent[], filter?: DebugLogFilter): DebugEvent[] {
Expand Down
28 changes: 20 additions & 8 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,25 @@ app.on('window-all-closed', () => {
}
});

app.on('before-quit', () => {
app.on('before-quit', (event) => {
if (isQuitting) return;
event.preventDefault();
isQuitting = true;
getDebugLogger().info({ domain: 'app', event: 'app.before-quit', data: { installingUpdate: isInstallingUpdate() } });
globalShortcut.unregisterAll();
unwatchAll();
destroyAllGateways();
destroyTray();
destroyQuickLaunch();
closeDatabase();

const logger = getDebugLogger();
logger.info({ domain: 'app', event: 'app.before-quit', data: { installingUpdate: isInstallingUpdate() } });

// Flush pending debug log writes before exit, then complete shutdown
logger
.flush()
.catch(() => {})
.finally(() => {
globalShortcut.unregisterAll();
unwatchAll();
destroyAllGateways();
destroyTray();
destroyQuickLaunch();
closeDatabase();
app.quit();
});
});
4 changes: 3 additions & 1 deletion packages/desktop/test/debug-observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('debug observability foundation', () => {
rmSync(dir, { recursive: true, force: true });
});

it('stores recent events and writes ndjson to disk', () => {
it('stores recent events and writes ndjson to disk', async () => {
const seen: string[] = [];
const logger = createDebugLogger({
debugDir: dir,
Expand All @@ -56,6 +56,8 @@ describe('debug observability foundation', () => {
logger.info({ domain: 'gateway', event: 'gateway.connect.start', gatewayId: 'gw-1' });
logger.error({ domain: 'ipc', event: 'ipc.ws.send-message.failed', error: { message: 'not connected' } });

await logger.flush();

const events = logger.getRecentEvents();
expect(events).toHaveLength(2);
expect(events[0]?.event).toBe('gateway.connect.start');
Expand Down