From 0ff8b5c9d89f60f19322e79348bee44dd5922cfc Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 15 Feb 2026 00:32:09 +0000 Subject: [PATCH] feat(monitoring): add structured monitoring logger Fire-and-forget logger that writes to the monitoring-logs collection with log level filtering via MONITORING_LOG_LEVEL env var. Falls back to console output when Payload is not yet initialized. Co-Authored-By: Claude Opus 4.6 --- src/lib/monitoring/monitoring-logger.ts | 94 ++++++++++++ .../monitoring/monitoring-logger.unit.spec.ts | 144 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 src/lib/monitoring/monitoring-logger.ts create mode 100644 tests/unit/monitoring/monitoring-logger.unit.spec.ts diff --git a/src/lib/monitoring/monitoring-logger.ts b/src/lib/monitoring/monitoring-logger.ts new file mode 100644 index 0000000..92bc4b3 --- /dev/null +++ b/src/lib/monitoring/monitoring-logger.ts @@ -0,0 +1,94 @@ +/** + * Monitoring Logger + * + * Structured logger that writes entries to the monitoring-logs collection. + * Falls back to console output when Payload is not yet initialized. + */ + +import type { LogLevel, LogSource } from './types.js' + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, + fatal: 4, +} + +function getMinLevel(): LogLevel { + return (process.env.MONITORING_LOG_LEVEL as LogLevel) || 'info' +} + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[getMinLevel()] +} + +export interface LogContext { + requestId?: string + userId?: number + tenant?: number + duration?: number + [key: string]: unknown +} + +export interface MonitoringLoggerInstance { + debug(message: string, context?: LogContext): void + info(message: string, context?: LogContext): void + warn(message: string, context?: LogContext): void + error(message: string, context?: LogContext): void + fatal(message: string, context?: LogContext): void +} + +async function writeLog( + source: LogSource, + level: LogLevel, + message: string, + context?: LogContext, +): Promise { + if (!shouldLog(level)) return + + try { + const { getPayload } = await import('payload') + const config = (await import('@payload-config')).default + const payload = await getPayload({ config }) + + const { requestId, userId, tenant, duration, ...rest } = context || {} + + await payload.create({ + collection: 'monitoring-logs', + data: { + level, + source, + message, + context: Object.keys(rest).length > 0 ? rest : undefined, + requestId, + userId, + tenant, + duration, + }, + }) + } catch { + // Fallback to console if Payload is not yet initialized + const prefix = `[${source}][${level.toUpperCase()}]` + console.log(prefix, message, context || '') + } +} + +export function createMonitoringLogger(source: LogSource): MonitoringLoggerInstance { + function log(level: LogLevel): (message: string, context?: LogContext) => void { + return function logMessage(message: string, context?: LogContext): void { + // Fire-and-forget -- don't block the caller + writeLog(source, level, message, context).catch(function onError(err) { + console.error(`[MonitoringLogger] Failed to write ${level} log:`, err) + }) + } + } + + return { + debug: log('debug'), + info: log('info'), + warn: log('warn'), + error: log('error'), + fatal: log('fatal'), + } +} diff --git a/tests/unit/monitoring/monitoring-logger.unit.spec.ts b/tests/unit/monitoring/monitoring-logger.unit.spec.ts new file mode 100644 index 0000000..1236382 --- /dev/null +++ b/tests/unit/monitoring/monitoring-logger.unit.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockCreate = vi.fn().mockResolvedValue({ id: '1' }) +const mockGetPayload = vi.fn().mockResolvedValue({ create: mockCreate }) + +vi.mock('payload', () => ({ + getPayload: mockGetPayload, +})) + +vi.mock('@payload-config', () => ({ + default: {}, +})) + +import { createMonitoringLogger } from '@/lib/monitoring/monitoring-logger' + +/** Wait long enough for fire-and-forget promises to settle. */ +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)) +} + +describe('MonitoringLogger', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetPayload.mockResolvedValue({ create: mockCreate }) + mockCreate.mockResolvedValue({ id: '1' }) + delete process.env.MONITORING_LOG_LEVEL + }) + + afterEach(async () => { + // Drain any pending fire-and-forget promises to prevent cross-test leakage + await flush() + }) + + it('creates a logger with all five log methods', () => { + const logger = createMonitoringLogger('cron') + + expect(logger).toHaveProperty('debug') + expect(logger).toHaveProperty('info') + expect(logger).toHaveProperty('warn') + expect(logger).toHaveProperty('error') + expect(logger).toHaveProperty('fatal') + }) + + it('writes to payload when log level meets threshold', async () => { + const logger = createMonitoringLogger('cron') + logger.info('Cron job completed', { duration: 3500 }) + + await flush() + + expect(mockGetPayload).toHaveBeenCalled() + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'monitoring-logs', + data: expect.objectContaining({ + level: 'info', + source: 'cron', + message: 'Cron job completed', + duration: 3500, + }), + }), + ) + }) + + it('filters out messages below the configured minimum level', async () => { + process.env.MONITORING_LOG_LEVEL = 'warn' + + const logger = createMonitoringLogger('payload') + logger.debug('This should not be logged') + logger.info('This should not be logged either') + + await flush() + + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('allows messages at or above the configured minimum level', async () => { + process.env.MONITORING_LOG_LEVEL = 'warn' + + const logger = createMonitoringLogger('email') + logger.warn('SMTP timeout') + + await flush() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'monitoring-logs', + data: expect.objectContaining({ + level: 'warn', + source: 'email', + message: 'SMTP timeout', + }), + }), + ) + }) + + it('passes context fields to the payload create call', async () => { + const logger = createMonitoringLogger('sync') + logger.info('Sync completed', { requestId: 'req-123', userId: 42, tenant: 1, duration: 1500 }) + + await flush() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + requestId: 'req-123', + userId: 42, + tenant: 1, + duration: 1500, + }), + }), + ) + }) + + it('separates extra context fields from known fields', async () => { + const logger = createMonitoringLogger('oauth') + logger.error('Token refresh failed', { userId: 5, provider: 'meta', errorCode: 'EXPIRED' }) + + await flush() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 5, + context: { provider: 'meta', errorCode: 'EXPIRED' }, + }), + }), + ) + }) + + it('falls back to console when payload is unavailable', async () => { + mockGetPayload.mockRejectedValueOnce(new Error('Not initialized')) + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const logger = createMonitoringLogger('cron') + logger.info('Fallback test') + + await flush() + + expect(consoleSpy).toHaveBeenCalledWith('[cron][INFO]', 'Fallback test', expect.anything()) + + consoleSpy.mockRestore() + }) +})