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
37 changes: 36 additions & 1 deletion packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@ export function createRequestLogger<T extends object = Record<string, unknown>>(
requestId: options.requestId,
}
let hasError = false
let hasWarn = false

function addRequestLog(level: 'info' | 'warn', message: string): void {
const entry = {
level,
message,
timestamp: new Date().toISOString(),
}

const logs = Array.isArray(context.logs)
? [...context.logs, entry]
: [entry]

context = {
...context,
logs,
}
}

return {
set(data: FieldContext<T>): void {
Expand All @@ -294,10 +312,27 @@ export function createRequestLogger<T extends object = Record<string, unknown>>(
context = deepDefaults(errorData, context) as Record<string, unknown>
},

info(message: string, infoContext?: FieldContext<T>): void {
addRequestLog('info', message)
if (infoContext) {
const { logs: _, ...rest } = infoContext as Record<string, unknown>
context = deepDefaults(rest, context) as Record<string, unknown>
}
},

warn(message: string, warnContext?: FieldContext<T>): void {
hasWarn = true
addRequestLog('warn', message)
if (warnContext) {
const { logs: _, ...rest } = warnContext as Record<string, unknown>
context = deepDefaults(rest, context) as Record<string, unknown>
}
},

emit(overrides?: FieldContext<T> & { _forceKeep?: boolean }): WideEvent | null {
const durationMs = Date.now() - startTime
const duration = formatDuration(durationMs)
const level: LogLevel = hasError ? 'error' : 'info'
const level: LogLevel = hasError ? 'error' : hasWarn ? 'warn' : 'info'

// Extract _forceKeep from overrides (set by evlog:emit:keep hook)
const { _forceKeep, ...restOverrides } = (overrides ?? {}) as Record<string, unknown> & { _forceKeep?: boolean }
Expand Down
20 changes: 20 additions & 0 deletions packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,16 @@ export type DeepPartial<T> = T extends Array<unknown>
export interface InternalFields {
status?: number
service?: string
logs?: RequestLogEntry[]
}

/**
* Request-scoped log entry captured during a request lifecycle.
*/
export interface RequestLogEntry {
level: 'info' | 'warn'
message: string
timestamp: string
}

/**
Expand Down Expand Up @@ -371,6 +381,16 @@ export interface RequestLogger<T extends object = Record<string, unknown>> {
*/
error: (error: Error | string, context?: FieldContext<T>) => void

/**
* Capture an informational message inside the request wide event.
*/
info: (message: string, context?: FieldContext<T>) => void

/**
* Capture a warning message inside the request wide event.
*/
warn: (message: string, context?: FieldContext<T>) => void

/**
* Emit the final wide event with all accumulated context.
* Returns the emitted WideEvent, or null if the log was sampled out.
Expand Down
82 changes: 82 additions & 0 deletions packages/evlog/test/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,88 @@ describe('createRequestLogger', () => {
expect(context.step).toBe('payment')
})

it('captures info messages in logs array', () => {
const logger = createRequestLogger({})

logger.info('Cache miss, fetching from database')

const context = logger.getContext()
expect(context.logs).toEqual([
{
level: 'info',
message: 'Cache miss, fetching from database',
timestamp: expect.any(String),
},
])
})

it('captures warning messages in logs array and escalates final level', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const logger = createRequestLogger({})

logger.warn('Deprecated parameter used')
logger.emit()

const context = logger.getContext()
expect(context.logs).toEqual([
{
level: 'warn',
message: 'Deprecated parameter used',
timestamp: expect.any(String),
},
])

expect(warnSpy).toHaveBeenCalled()
const output = warnSpy.mock.calls[0]?.[0]
expect(output).toContain('"level":"warn"')
})

it('preserves chronological request logs and escalates warn over info', () => {
const logger = createRequestLogger({})

logger.info('User authenticated')
logger.info('Cache miss')
logger.warn('Deprecated parameter used')

const result = logger.emit()

expect(result).not.toBeNull()
expect(result).toHaveProperty('level', 'warn')
expect(result).toHaveProperty('logs')
expect(Array.isArray(result?.logs)).toBe(true)
expect((result?.logs as Array<Record<string, unknown>>).map(entry => entry.level)).toEqual(['info', 'info', 'warn'])
expect((result?.logs as Array<Record<string, unknown>>).map(entry => entry.message)).toEqual([
'User authenticated',
'Cache miss',
'Deprecated parameter used',
])
})

it('merges context passed to info() and warn()', () => {
const logger = createRequestLogger({})

logger.info('Starting request', { user: { id: '123' } })
logger.warn('Slow downstream call', { downstream: { service: 'billing' } })

const context = logger.getContext()
expect(context.user).toEqual({ id: '123' })
expect(context.downstream).toEqual({ service: 'billing' })
})

it('does not clobber logs when context contains logs key', () => {
const logger = createRequestLogger({})

logger.info('First entry')
logger.info('Second entry', { logs: 'should be ignored' } as any)
logger.warn('Third entry', { logs: [{ fake: true }] } as any)

const context = logger.getContext()
expect(context.logs).toHaveLength(3)
expect(context.logs[0].message).toBe('First entry')
expect(context.logs[1].message).toBe('Second entry')
expect(context.logs[2].message).toBe('Third entry')
})

it('captures custom error properties (statusCode, data, cause)', () => {
const logger = createRequestLogger({})
const error = Object.assign(new Error('Something went wrong'), {
Expand Down
Loading