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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-15 00:32:09 +00:00
parent 97c8f32967
commit 0ff8b5c9d8
2 changed files with 238 additions and 0 deletions

View file

@ -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<LogLevel, number> = {
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<void> {
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'),
}
}

View file

@ -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<void> {
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()
})
})