mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
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:
parent
97c8f32967
commit
0ff8b5c9d8
2 changed files with 238 additions and 0 deletions
94
src/lib/monitoring/monitoring-logger.ts
Normal file
94
src/lib/monitoring/monitoring-logger.ts
Normal 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'),
|
||||
}
|
||||
}
|
||||
144
tests/unit/monitoring/monitoring-logger.unit.spec.ts
Normal file
144
tests/unit/monitoring/monitoring-logger.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue