@vobs/logger 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +25 -0
- package/src/index.test.ts +141 -0
- package/src/index.ts +324 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @vobs/logger
|
|
2
|
+
|
|
3
|
+
Structured, level-filtered logging with pluggable transports, context scoping, and default redaction.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/logger
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createLogger, createMemoryTransport } from '@vobs/logger'
|
|
15
|
+
|
|
16
|
+
const memory = createMemoryTransport(100)
|
|
17
|
+
const logger = createLogger({
|
|
18
|
+
level: 'info',
|
|
19
|
+
context: { app: 'console' },
|
|
20
|
+
transports: [memory]
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const request = logger.child({ requestId: 'request-1' })
|
|
24
|
+
request.warn('request failed', { status: 503 })
|
|
25
|
+
await logger.flush()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
| Signature | Description |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `createLogger(options?: LoggerOptions): Logger` | Creates a logger; defaults to level `'info'` and a console transport. |
|
|
33
|
+
| `logger.level: LogLevel` | Minimum level: `'debug'`, `'info'`, `'warn'`, or `'error'`. |
|
|
34
|
+
| `logger.log(level, message, context?)` / `debug` / `info` / `warn` / `error` | Emit entries; below-level calls are dropped. |
|
|
35
|
+
| `logger.child(context: LogContext): Logger` | Returns a logger whose entries merge the parent and child context. |
|
|
36
|
+
| `logger.flush(): Promise<void>` | Waits for pending async writes, then calls `flush` on each transport. |
|
|
37
|
+
| `logger.dispose(): void` | Stops the logger; the root logger also disposes owned transports. |
|
|
38
|
+
| `createConsoleTransport(target?: ConsoleLike): LogTransport` | Writes `[level] message` plus context to a console-like target. |
|
|
39
|
+
| `createMemoryTransport(limit?): MemoryLogTransport` | Keeps entries in a ring buffer with `entries` and `clear()`. |
|
|
40
|
+
| `loggerPlugin(options?): VobsPlugin` | Provides the logger through `LOGGER_KEY`. |
|
|
41
|
+
| `useLogger(): Logger` | Injects the logger inside components. |
|
|
42
|
+
|
|
43
|
+
Context objects are sanitized before writing: sensitive keys (`password`, `token`, `authorization`, `cookie`, and more, extendable via `redactKeys`) become `[REDACTED]`, circular references become `[Circular]`, depth is capped by `maxDepth` (default 8), and `Error`/`Date` values are serialized. Transport write and flush failures are reported through `onTransportError` and never make logging throw.
|
|
44
|
+
|
|
45
|
+
## Types
|
|
46
|
+
|
|
47
|
+
`Logger`, `LogLevel`, `LogEntry`, `LogContext`, `LogObject`, `LogValue`, `LogTransport`, `ConsoleLike`, `MemoryLogTransport`, `LoggerOptions`, `LoggerPluginOptions`, `LoggerErrorCode`
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/logger",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"description": "Structured, transport-based logging for Vobs applications.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@vobs/reactivity": "1.0.0",
|
|
20
|
+
"@vobs/vobs": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest --environment jsdom"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createText, createVobs } from '@vobs/vobs'
|
|
3
|
+
import {
|
|
4
|
+
LOGGER_KEY,
|
|
5
|
+
LoggerError,
|
|
6
|
+
createConsoleTransport,
|
|
7
|
+
createLogger,
|
|
8
|
+
createMemoryTransport,
|
|
9
|
+
loggerPlugin,
|
|
10
|
+
useLogger
|
|
11
|
+
} from './index'
|
|
12
|
+
|
|
13
|
+
describe('@vobs/logger', () => {
|
|
14
|
+
it('按级别过滤,并合并子日志的结构化上下文', () => {
|
|
15
|
+
const memory = createMemoryTransport()
|
|
16
|
+
const logger = createLogger({
|
|
17
|
+
level: 'info',
|
|
18
|
+
context: { app: 'console', release: 3 },
|
|
19
|
+
transports: [memory],
|
|
20
|
+
clock: () => new Date('2026-09-03T00:00:00.000Z')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
logger.debug('ignored')
|
|
24
|
+
logger.child({ requestId: 'request-1' }).warn('request failed', { status: 503 })
|
|
25
|
+
|
|
26
|
+
expect(memory.entries).toEqual([{
|
|
27
|
+
timestamp: '2026-09-03T00:00:00.000Z',
|
|
28
|
+
level: 'warn',
|
|
29
|
+
message: 'request failed',
|
|
30
|
+
context: { app: 'console', release: 3, requestId: 'request-1', status: 503 }
|
|
31
|
+
}])
|
|
32
|
+
expect(Object.isFrozen(memory.entries[0])).toBe(true)
|
|
33
|
+
expect(Object.isFrozen(memory.entries[0].context)).toBe(true)
|
|
34
|
+
logger.dispose()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('默认脱敏敏感键,安全处理错误、深层对象和循环值', () => {
|
|
38
|
+
const memory = createMemoryTransport()
|
|
39
|
+
const circular: { self?: unknown } = {}
|
|
40
|
+
circular.self = circular
|
|
41
|
+
const logger = createLogger({ transports: [memory], maxDepth: 2 })
|
|
42
|
+
|
|
43
|
+
logger.error('login failed', {
|
|
44
|
+
token: 'private',
|
|
45
|
+
nested: { authorization: 'secret', value: true },
|
|
46
|
+
error: new Error('denied'),
|
|
47
|
+
circular,
|
|
48
|
+
deep: { one: { two: { three: true } } }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(memory.entries[0].context).toMatchObject({
|
|
52
|
+
token: '[REDACTED]',
|
|
53
|
+
nested: { authorization: '[REDACTED]', value: true },
|
|
54
|
+
error: { name: 'Error', message: 'denied' },
|
|
55
|
+
circular: { self: '[Circular]' },
|
|
56
|
+
deep: { one: { two: '[MaxDepth]' } }
|
|
57
|
+
})
|
|
58
|
+
logger.dispose()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('flush 等待异步 transport,并隔离 transport 写入和 flush 错误', async () => {
|
|
62
|
+
let resolveWrite: (() => void) | undefined
|
|
63
|
+
const onTransportError = vi.fn()
|
|
64
|
+
const asyncTransport = {
|
|
65
|
+
write: vi.fn(() => new Promise<void>(resolve => { resolveWrite = resolve })),
|
|
66
|
+
flush: vi.fn()
|
|
67
|
+
}
|
|
68
|
+
const brokenTransport = {
|
|
69
|
+
write: () => { throw new Error('write failed') },
|
|
70
|
+
flush: () => { throw new Error('flush failed') }
|
|
71
|
+
}
|
|
72
|
+
const logger = createLogger({ transports: [asyncTransport, brokenTransport], onTransportError })
|
|
73
|
+
logger.info('queued')
|
|
74
|
+
const flushed = logger.flush()
|
|
75
|
+
expect(asyncTransport.flush).not.toHaveBeenCalled()
|
|
76
|
+
resolveWrite?.()
|
|
77
|
+
await flushed
|
|
78
|
+
|
|
79
|
+
expect(asyncTransport.flush).toHaveBeenCalledTimes(1)
|
|
80
|
+
expect(onTransportError).toHaveBeenCalledWith(expect.any(Error), brokenTransport, expect.objectContaining({ message: 'queued' }))
|
|
81
|
+
expect(onTransportError).toHaveBeenCalledWith(expect.any(Error), brokenTransport, undefined)
|
|
82
|
+
logger.dispose()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('console transport 使用对应级别输出,内存 transport 可以限制和清空', () => {
|
|
86
|
+
const output = vi.fn()
|
|
87
|
+
const consoleTransport = createConsoleTransport({ warn: output })
|
|
88
|
+
const memory = createMemoryTransport(2)
|
|
89
|
+
const logger = createLogger({ level: 'debug', transports: [consoleTransport, memory] })
|
|
90
|
+
logger.warn('notice', { count: 1 })
|
|
91
|
+
logger.info('first')
|
|
92
|
+
logger.info('second')
|
|
93
|
+
|
|
94
|
+
expect(output).toHaveBeenCalledWith('[warn] notice', { count: 1 })
|
|
95
|
+
expect(memory.entries.map(entry => entry.message)).toEqual(['first', 'second'])
|
|
96
|
+
memory.clear()
|
|
97
|
+
expect(memory.entries).toEqual([])
|
|
98
|
+
logger.dispose()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('子 Logger 销毁只停止自己的写入,不关闭父 Logger 的共享 transport', () => {
|
|
102
|
+
const memory = createMemoryTransport()
|
|
103
|
+
const logger = createLogger({ transports: [memory] })
|
|
104
|
+
const child = logger.child({ requestId: 'request-1' })
|
|
105
|
+
|
|
106
|
+
child.dispose()
|
|
107
|
+
child.info('ignored')
|
|
108
|
+
logger.info('parent remains active')
|
|
109
|
+
|
|
110
|
+
expect(memory.entries.map(entry => entry.message)).toEqual(['parent remains active'])
|
|
111
|
+
logger.dispose()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('loggerPlugin 注入上下文并在应用销毁后停止写入自有 logger', () => {
|
|
115
|
+
const memory = createMemoryTransport()
|
|
116
|
+
let injected: ReturnType<typeof createLogger> | undefined
|
|
117
|
+
const app = createVobs({
|
|
118
|
+
render: () => createText('logger'),
|
|
119
|
+
plugins: [
|
|
120
|
+
loggerPlugin({ transports: [memory] }),
|
|
121
|
+
{ name: 'consumer', install(context) { injected = context.inject(LOGGER_KEY) } }
|
|
122
|
+
]
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
injected?.info('before destroy')
|
|
126
|
+
app.destroy()
|
|
127
|
+
injected?.info('after destroy')
|
|
128
|
+
expect(memory.entries.map(entry => entry.message)).toEqual(['before destroy'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('未安装插件时 useLogger 给出明确错误', () => {
|
|
132
|
+
const app = createVobs({ render: () => {
|
|
133
|
+
useLogger()
|
|
134
|
+
return createText('')
|
|
135
|
+
} })
|
|
136
|
+
expect(() => app.mount(document.createElement('div'))).toThrowError(
|
|
137
|
+
expect.objectContaining({ code: 'LOGGER_CONTEXT_MISSING' })
|
|
138
|
+
)
|
|
139
|
+
expect(LoggerError).toBeDefined()
|
|
140
|
+
})
|
|
141
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { getCurrentOwner, onDispose } from '@vobs/reactivity'
|
|
2
|
+
import { createInjectionKey, inject, type InjectionKey, type VobsPlugin } from '@vobs/vobs'
|
|
3
|
+
|
|
4
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
5
|
+
|
|
6
|
+
export type LogValue = string | number | boolean | null | Readonly<LogObject> | readonly LogValue[]
|
|
7
|
+
|
|
8
|
+
export interface LogObject {
|
|
9
|
+
readonly [key: string]: LogValue
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type LogContext = Readonly<Record<string, unknown>>
|
|
13
|
+
|
|
14
|
+
export interface LogEntry {
|
|
15
|
+
readonly timestamp: string
|
|
16
|
+
readonly level: LogLevel
|
|
17
|
+
readonly message: string
|
|
18
|
+
readonly context: Readonly<LogObject>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LogTransport {
|
|
22
|
+
write(entry: LogEntry): void | PromiseLike<void>
|
|
23
|
+
flush?(): void | PromiseLike<void>
|
|
24
|
+
dispose?(): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Logger {
|
|
28
|
+
readonly level: LogLevel
|
|
29
|
+
log(level: LogLevel, message: string, context?: LogContext): void
|
|
30
|
+
debug(message: string, context?: LogContext): void
|
|
31
|
+
info(message: string, context?: LogContext): void
|
|
32
|
+
warn(message: string, context?: LogContext): void
|
|
33
|
+
error(message: string, context?: LogContext): void
|
|
34
|
+
child(context: LogContext): Logger
|
|
35
|
+
flush(): Promise<void>
|
|
36
|
+
dispose(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LoggerOptions {
|
|
40
|
+
readonly level?: LogLevel
|
|
41
|
+
readonly context?: LogContext
|
|
42
|
+
readonly transports?: readonly LogTransport[]
|
|
43
|
+
readonly redactKeys?: readonly string[]
|
|
44
|
+
readonly maxDepth?: number
|
|
45
|
+
readonly clock?: () => Date
|
|
46
|
+
readonly onTransportError?: (error: unknown, transport: LogTransport, entry?: LogEntry) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LoggerPluginOptions extends LoggerOptions {
|
|
50
|
+
readonly logger?: Logger
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConsoleLike {
|
|
54
|
+
debug?: (...data: unknown[]) => void
|
|
55
|
+
info?: (...data: unknown[]) => void
|
|
56
|
+
warn?: (...data: unknown[]) => void
|
|
57
|
+
error?: (...data: unknown[]) => void
|
|
58
|
+
log?: (...data: unknown[]) => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MemoryLogTransport extends LogTransport {
|
|
62
|
+
readonly entries: readonly LogEntry[]
|
|
63
|
+
clear(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type LoggerErrorCode = 'LOGGER_CONTEXT_MISSING' | 'INVALID_LEVEL' | 'INVALID_TRANSPORT' | 'INVALID_MAX_DEPTH'
|
|
67
|
+
|
|
68
|
+
export class LoggerError extends Error {
|
|
69
|
+
readonly code: LoggerErrorCode
|
|
70
|
+
|
|
71
|
+
constructor(code: LoggerErrorCode, message: string) {
|
|
72
|
+
super(message)
|
|
73
|
+
this.name = 'LoggerError'
|
|
74
|
+
this.code = code
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const LOGGER_KEY: InjectionKey<Logger> = createInjectionKey<Logger>('vobs.logger')
|
|
79
|
+
|
|
80
|
+
const levels: Readonly<Record<LogLevel, number>> = {
|
|
81
|
+
debug: 10,
|
|
82
|
+
info: 20,
|
|
83
|
+
warn: 30,
|
|
84
|
+
error: 40
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const defaultRedactKeys = ['password', 'passwd', 'secret', 'token', 'authorization', 'cookie']
|
|
88
|
+
|
|
89
|
+
interface LoggerState {
|
|
90
|
+
readonly level: LogLevel
|
|
91
|
+
readonly transports: readonly LogTransport[]
|
|
92
|
+
readonly redactKeys: ReadonlySet<string>
|
|
93
|
+
readonly maxDepth: number
|
|
94
|
+
readonly clock: () => Date
|
|
95
|
+
readonly onTransportError: LoggerOptions['onTransportError']
|
|
96
|
+
readonly pending: Set<Promise<void>>
|
|
97
|
+
disposed: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
101
|
+
const level = options.level ?? 'info'
|
|
102
|
+
if (!isLogLevel(level)) throw new LoggerError('INVALID_LEVEL', `Vobs Logger: 不支持日志级别 ${String(level)}`)
|
|
103
|
+
|
|
104
|
+
const maxDepth = options.maxDepth ?? 8
|
|
105
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 0) {
|
|
106
|
+
throw new LoggerError('INVALID_MAX_DEPTH', 'Vobs Logger: maxDepth 必须是大于等于 0 的整数')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const transports = options.transports ?? [createConsoleTransport()]
|
|
110
|
+
if (transports.some(transport => !transport || typeof transport.write !== 'function')) {
|
|
111
|
+
throw new LoggerError('INVALID_TRANSPORT', 'Vobs Logger: transport 必须提供 write(entry)')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const state: LoggerState = {
|
|
115
|
+
level,
|
|
116
|
+
transports,
|
|
117
|
+
redactKeys: new Set((options.redactKeys ?? defaultRedactKeys).map(key => key.toLowerCase())),
|
|
118
|
+
maxDepth,
|
|
119
|
+
clock: options.clock ?? (() => new Date()),
|
|
120
|
+
onTransportError: options.onTransportError,
|
|
121
|
+
pending: new Set(),
|
|
122
|
+
disposed: false
|
|
123
|
+
}
|
|
124
|
+
const logger = createLoggerScope(state, options.context ?? {}, true)
|
|
125
|
+
if (getCurrentOwner()) onDispose(logger.dispose)
|
|
126
|
+
return logger
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createConsoleTransport(target: ConsoleLike | undefined = globalThis.console): LogTransport {
|
|
130
|
+
return {
|
|
131
|
+
write(entry): void {
|
|
132
|
+
const output = target?.[entry.level] ?? target?.log
|
|
133
|
+
output?.call(target, `[${entry.level}] ${entry.message}`, entry.context)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function createMemoryTransport(limit = Infinity): MemoryLogTransport {
|
|
139
|
+
if ((!Number.isInteger(limit) && limit !== Infinity) || limit <= 0) {
|
|
140
|
+
throw new RangeError('Vobs Logger: memory transport 的 limit 必须是正整数或 Infinity')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entries: LogEntry[] = []
|
|
144
|
+
return {
|
|
145
|
+
get entries(): readonly LogEntry[] {
|
|
146
|
+
return entries
|
|
147
|
+
},
|
|
148
|
+
write(entry): void {
|
|
149
|
+
entries.push(entry)
|
|
150
|
+
if (entries.length > limit) entries.splice(0, entries.length - limit)
|
|
151
|
+
},
|
|
152
|
+
clear(): void {
|
|
153
|
+
entries.length = 0
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function loggerPlugin(options: LoggerPluginOptions = {}): VobsPlugin {
|
|
159
|
+
return {
|
|
160
|
+
name: '@vobs/logger',
|
|
161
|
+
version: '0.1.0',
|
|
162
|
+
install(context) {
|
|
163
|
+
const ownedLogger = options.logger ? undefined : createLogger(options)
|
|
164
|
+
context.provide(LOGGER_KEY, options.logger ?? ownedLogger!)
|
|
165
|
+
return () => ownedLogger?.dispose()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function useLogger(): Logger {
|
|
171
|
+
const logger = inject(LOGGER_KEY)
|
|
172
|
+
if (!logger) throw new LoggerError('LOGGER_CONTEXT_MISSING', 'Vobs Logger: 找不到上下文,请安装 loggerPlugin')
|
|
173
|
+
return logger
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createLoggerScope(state: LoggerState, baseContext: LogContext, ownsTransports = false): Logger {
|
|
177
|
+
const context = { ...baseContext }
|
|
178
|
+
let disposed = false
|
|
179
|
+
return {
|
|
180
|
+
level: state.level,
|
|
181
|
+
|
|
182
|
+
log(level, message, details = {}): void {
|
|
183
|
+
if (state.disposed || disposed || !isLogLevel(level) || levels[level] < levels[state.level]) return
|
|
184
|
+
const entry = freezeEntry({
|
|
185
|
+
timestamp: state.clock().toISOString(),
|
|
186
|
+
level,
|
|
187
|
+
message,
|
|
188
|
+
context: sanitizeContext({ ...context, ...details }, state.redactKeys, state.maxDepth)
|
|
189
|
+
})
|
|
190
|
+
for (const transport of state.transports) writeToTransport(state, transport, entry)
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
debug(message, details): void {
|
|
194
|
+
this.log('debug', message, details)
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
info(message, details): void {
|
|
198
|
+
this.log('info', message, details)
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
warn(message, details): void {
|
|
202
|
+
this.log('warn', message, details)
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
error(message, details): void {
|
|
206
|
+
this.log('error', message, details)
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
child(details): Logger {
|
|
210
|
+
return createLoggerScope(state, { ...context, ...details })
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async flush(): Promise<void> {
|
|
214
|
+
while (state.pending.size > 0) await Promise.all([...state.pending])
|
|
215
|
+
for (const transport of state.transports) {
|
|
216
|
+
try {
|
|
217
|
+
await transport.flush?.()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
reportTransportError(state, error, transport)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
dispose(): void {
|
|
225
|
+
if (disposed) return
|
|
226
|
+
disposed = true
|
|
227
|
+
if (!ownsTransports || state.disposed) return
|
|
228
|
+
state.disposed = true
|
|
229
|
+
for (const transport of state.transports) {
|
|
230
|
+
try {
|
|
231
|
+
transport.dispose?.()
|
|
232
|
+
} catch (error) {
|
|
233
|
+
reportTransportError(state, error, transport)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function writeToTransport(state: LoggerState, transport: LogTransport, entry: LogEntry): void {
|
|
241
|
+
try {
|
|
242
|
+
const result = transport.write(entry)
|
|
243
|
+
if (!isPromiseLike(result)) return
|
|
244
|
+
const pending = Promise.resolve(result).catch(error => {
|
|
245
|
+
reportTransportError(state, error, transport, entry)
|
|
246
|
+
})
|
|
247
|
+
state.pending.add(pending)
|
|
248
|
+
void pending.then(() => state.pending.delete(pending))
|
|
249
|
+
} catch (error) {
|
|
250
|
+
reportTransportError(state, error, transport, entry)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function reportTransportError(
|
|
255
|
+
state: LoggerState,
|
|
256
|
+
error: unknown,
|
|
257
|
+
transport: LogTransport,
|
|
258
|
+
entry?: LogEntry
|
|
259
|
+
): void {
|
|
260
|
+
try {
|
|
261
|
+
state.onTransportError?.(error, transport, entry)
|
|
262
|
+
} catch {
|
|
263
|
+
// Transport error reporting must never make application logging throw.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sanitizeContext(input: LogContext, redactKeys: ReadonlySet<string>, maxDepth: number): LogObject {
|
|
268
|
+
const seen = new WeakSet<object>()
|
|
269
|
+
return Object.freeze(sanitizeObject(input, redactKeys, maxDepth, seen))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sanitizeObject(
|
|
273
|
+
input: LogContext,
|
|
274
|
+
redactKeys: ReadonlySet<string>,
|
|
275
|
+
depth: number,
|
|
276
|
+
seen: WeakSet<object>
|
|
277
|
+
): LogObject {
|
|
278
|
+
const output: Record<string, LogValue> = {}
|
|
279
|
+
for (const [key, value] of Object.entries(input)) {
|
|
280
|
+
output[key] = redactKeys.has(key.toLowerCase())
|
|
281
|
+
? '[REDACTED]'
|
|
282
|
+
: sanitizeValue(value, redactKeys, depth, seen)
|
|
283
|
+
}
|
|
284
|
+
return Object.freeze(output)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function sanitizeValue(
|
|
288
|
+
value: unknown,
|
|
289
|
+
redactKeys: ReadonlySet<string>,
|
|
290
|
+
depth: number,
|
|
291
|
+
seen: WeakSet<object>
|
|
292
|
+
): LogValue {
|
|
293
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
|
294
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
|
|
295
|
+
if (typeof value === 'bigint' || typeof value === 'symbol' || typeof value === 'undefined') return String(value)
|
|
296
|
+
if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
|
|
297
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString()
|
|
298
|
+
if (value instanceof Error) {
|
|
299
|
+
return Object.freeze({
|
|
300
|
+
name: value.name,
|
|
301
|
+
message: value.message,
|
|
302
|
+
...(value.stack ? { stack: value.stack } : {})
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
if (depth === 0) return '[MaxDepth]'
|
|
306
|
+
if (seen.has(value as object)) return '[Circular]'
|
|
307
|
+
seen.add(value as object)
|
|
308
|
+
if (Array.isArray(value)) {
|
|
309
|
+
return Object.freeze(value.map(item => sanitizeValue(item, redactKeys, depth - 1, seen)))
|
|
310
|
+
}
|
|
311
|
+
return sanitizeObject(value as LogContext, redactKeys, depth - 1, seen)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function freezeEntry(entry: LogEntry): LogEntry {
|
|
315
|
+
return Object.freeze(entry)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isPromiseLike(value: unknown): value is PromiseLike<void> {
|
|
319
|
+
return Boolean(value) && typeof (value as PromiseLike<void>).then === 'function'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isLogLevel(value: unknown): value is LogLevel {
|
|
323
|
+
return typeof value === 'string' && value in levels
|
|
324
|
+
}
|