mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 22:04:10 +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