@ontrails/logging 1.0.0-beta.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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +160 -0
- package/dist/env.d.ts +13 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +38 -0
- package/dist/env.js.map +1 -0
- package/dist/formatters.d.ts +8 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +72 -0
- package/dist/formatters.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/levels.d.ts +9 -0
- package/dist/levels.d.ts.map +1 -0
- package/dist/levels.js +52 -0
- package/dist/levels.js.map +1 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +80 -0
- package/dist/logger.js.map +1 -0
- package/dist/logtape/index.d.ts +24 -0
- package/dist/logtape/index.d.ts.map +1 -0
- package/dist/logtape/index.js +31 -0
- package/dist/logtape/index.js.map +1 -0
- package/dist/sinks.d.ts +13 -0
- package/dist/sinks.d.ts.map +1 -0
- package/dist/sinks.js +58 -0
- package/dist/sinks.js.map +1 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +25 -0
- package/src/__tests__/env.test.ts +54 -0
- package/src/__tests__/formatters.test.ts +119 -0
- package/src/__tests__/levels.test.ts +82 -0
- package/src/__tests__/logger.test.ts +279 -0
- package/src/__tests__/sinks.test.ts +181 -0
- package/src/env.ts +49 -0
- package/src/formatters.ts +101 -0
- package/src/index.ts +28 -0
- package/src/levels.ts +69 -0
- package/src/logger.ts +135 -0
- package/src/logtape/index.ts +68 -0
- package/src/sinks.ts +71 -0
- package/src/types.ts +99 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { createLogger } from '../logger.js';
|
|
4
|
+
import type { LogRecord, LogSink } from '../types.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const captureSink = (): { records: LogRecord[]; sink: LogSink } => {
|
|
11
|
+
const records: LogRecord[] = [];
|
|
12
|
+
const sink: LogSink = {
|
|
13
|
+
name: 'capture',
|
|
14
|
+
write(record: LogRecord): void {
|
|
15
|
+
records.push(record);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return { records, sink };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// createLogger basics
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe('createLogger', () => {
|
|
26
|
+
test('produces a working logger with default config', () => {
|
|
27
|
+
const { records, sink } = captureSink();
|
|
28
|
+
const logger = createLogger({ name: 'app', sinks: [sink] });
|
|
29
|
+
|
|
30
|
+
logger.info('hello');
|
|
31
|
+
expect(records).toHaveLength(1);
|
|
32
|
+
const [first] = records;
|
|
33
|
+
expect(first?.message).toBe('hello');
|
|
34
|
+
expect(first?.category).toBe('app');
|
|
35
|
+
expect(first?.level).toBe('info');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('logger.name returns the category', () => {
|
|
39
|
+
const logger = createLogger({ name: 'my.app', sinks: [] });
|
|
40
|
+
expect(logger.name).toBe('my.app');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('logger with no sinks does not throw', () => {
|
|
44
|
+
const logger = createLogger({ name: 'silent', sinks: [] });
|
|
45
|
+
expect(() => logger.info('hello')).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Level methods
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe('log level methods', () => {
|
|
54
|
+
test('all six methods dispatch to sinks at appropriate levels', () => {
|
|
55
|
+
const { records, sink } = captureSink();
|
|
56
|
+
const logger = createLogger({
|
|
57
|
+
level: 'trace',
|
|
58
|
+
name: 'app',
|
|
59
|
+
sinks: [sink],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
logger.trace('t');
|
|
63
|
+
logger.debug('d');
|
|
64
|
+
logger.info('i');
|
|
65
|
+
logger.warn('w');
|
|
66
|
+
logger.error('e');
|
|
67
|
+
logger.fatal('f');
|
|
68
|
+
|
|
69
|
+
expect(records).toHaveLength(6);
|
|
70
|
+
expect(records.map((r) => r.level)).toEqual([
|
|
71
|
+
'trace',
|
|
72
|
+
'debug',
|
|
73
|
+
'info',
|
|
74
|
+
'warn',
|
|
75
|
+
'error',
|
|
76
|
+
'fatal',
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('messages below configured level are suppressed', () => {
|
|
81
|
+
const { records, sink } = captureSink();
|
|
82
|
+
const logger = createLogger({
|
|
83
|
+
level: 'warn',
|
|
84
|
+
name: 'app',
|
|
85
|
+
sinks: [sink],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
logger.trace('t');
|
|
89
|
+
logger.debug('d');
|
|
90
|
+
logger.info('i');
|
|
91
|
+
logger.warn('w');
|
|
92
|
+
logger.error('e');
|
|
93
|
+
|
|
94
|
+
expect(records).toHaveLength(2);
|
|
95
|
+
expect(records.map((r) => r.level)).toEqual(['warn', 'error']);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// child()
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe('child()', () => {
|
|
104
|
+
test('inherits parent config and merges metadata', () => {
|
|
105
|
+
const { records, sink } = captureSink();
|
|
106
|
+
const logger = createLogger({
|
|
107
|
+
level: 'trace',
|
|
108
|
+
name: 'app',
|
|
109
|
+
sinks: [sink],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const child = logger.child({ requestId: 'r1' });
|
|
113
|
+
child.info('hello');
|
|
114
|
+
|
|
115
|
+
expect(records).toHaveLength(1);
|
|
116
|
+
const [first] = records;
|
|
117
|
+
expect(first?.metadata).toEqual({ requestId: 'r1' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('child logger metadata appears on every log record', () => {
|
|
121
|
+
const { records, sink } = captureSink();
|
|
122
|
+
const logger = createLogger({
|
|
123
|
+
level: 'trace',
|
|
124
|
+
name: 'app',
|
|
125
|
+
sinks: [sink],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const child = logger.child({ traceId: 't1' });
|
|
129
|
+
child.info('a');
|
|
130
|
+
child.warn('b');
|
|
131
|
+
|
|
132
|
+
expect(records[0]?.metadata).toEqual({ traceId: 't1' });
|
|
133
|
+
expect(records[1]?.metadata).toEqual({ traceId: 't1' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('multiple child() calls compose metadata correctly', () => {
|
|
137
|
+
const { records, sink } = captureSink();
|
|
138
|
+
const logger = createLogger({
|
|
139
|
+
level: 'trace',
|
|
140
|
+
name: 'app',
|
|
141
|
+
sinks: [sink],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const child1 = logger.child({ requestId: 'r1' });
|
|
145
|
+
const child2 = child1.child({ userId: 'u1' });
|
|
146
|
+
child2.info('hello');
|
|
147
|
+
|
|
148
|
+
const [first] = records;
|
|
149
|
+
expect(first?.metadata).toEqual({ requestId: 'r1', userId: 'u1' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('child metadata merges with per-call metadata', () => {
|
|
153
|
+
const { records, sink } = captureSink();
|
|
154
|
+
const logger = createLogger({
|
|
155
|
+
level: 'trace',
|
|
156
|
+
name: 'app',
|
|
157
|
+
sinks: [sink],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const child = logger.child({ requestId: 'r1' });
|
|
161
|
+
child.info('hello', { extra: true });
|
|
162
|
+
|
|
163
|
+
const [first] = records;
|
|
164
|
+
expect(first?.metadata).toEqual({ extra: true, requestId: 'r1' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('child preserves parent name', () => {
|
|
168
|
+
const logger = createLogger({ name: 'app.db', sinks: [] });
|
|
169
|
+
const child = logger.child({ rid: 'x' });
|
|
170
|
+
expect(child.name).toBe('app.db');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Redaction
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('redaction', () => {
|
|
179
|
+
test('redaction is applied to messages before sink dispatch', () => {
|
|
180
|
+
const { records, sink } = captureSink();
|
|
181
|
+
const logger = createLogger({
|
|
182
|
+
level: 'trace',
|
|
183
|
+
name: 'app',
|
|
184
|
+
sinks: [sink],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
logger.info('card: 4111-1111-1111-1111');
|
|
188
|
+
const [first] = records;
|
|
189
|
+
expect(first?.message).toBe('card: [REDACTED]');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('redaction is applied to metadata before sink dispatch', () => {
|
|
193
|
+
const { records, sink } = captureSink();
|
|
194
|
+
const logger = createLogger({
|
|
195
|
+
level: 'trace',
|
|
196
|
+
name: 'app',
|
|
197
|
+
sinks: [sink],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
logger.info('hello', { password: 's3cret' });
|
|
201
|
+
const [first] = records;
|
|
202
|
+
expect(first?.metadata['password']).toBe('[REDACTED]');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('default redaction scrubs patterns matching DEFAULT_PATTERNS', () => {
|
|
206
|
+
const { records, sink } = captureSink();
|
|
207
|
+
const logger = createLogger({
|
|
208
|
+
level: 'trace',
|
|
209
|
+
name: 'app',
|
|
210
|
+
sinks: [sink],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
logger.info('key: sk-abc123def456ghi');
|
|
214
|
+
const [first] = records;
|
|
215
|
+
expect(first?.message).toBe('key: [REDACTED]');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('default redaction scrubs keys matching DEFAULT_SENSITIVE_KEYS', () => {
|
|
219
|
+
const { records, sink } = captureSink();
|
|
220
|
+
const logger = createLogger({
|
|
221
|
+
level: 'trace',
|
|
222
|
+
name: 'app',
|
|
223
|
+
sinks: [sink],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
logger.info('auth', { apiKey: 'key-123', token: 'secret-value' });
|
|
227
|
+
const [first] = records;
|
|
228
|
+
expect(first?.metadata['token']).toBe('[REDACTED]');
|
|
229
|
+
expect(first?.metadata['apiKey']).toBe('[REDACTED]');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('custom redaction patterns are applied when provided', () => {
|
|
233
|
+
const { records, sink } = captureSink();
|
|
234
|
+
const logger = createLogger({
|
|
235
|
+
level: 'trace',
|
|
236
|
+
name: 'app',
|
|
237
|
+
redaction: {
|
|
238
|
+
patterns: [/secret-\w+/g],
|
|
239
|
+
},
|
|
240
|
+
sinks: [sink],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
logger.info('found secret-banana here');
|
|
244
|
+
const [first] = records;
|
|
245
|
+
expect(first?.message).toBe('found [REDACTED] here');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Hierarchical category filtering
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe('hierarchical category filtering', () => {
|
|
254
|
+
test('uses category-specific level', () => {
|
|
255
|
+
const { records, sink } = captureSink();
|
|
256
|
+
const logger = createLogger({
|
|
257
|
+
level: 'info',
|
|
258
|
+
levels: { 'app.db': 'debug' },
|
|
259
|
+
name: 'app.db',
|
|
260
|
+
sinks: [sink],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
logger.debug('query executed');
|
|
264
|
+
expect(records).toHaveLength(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('walks up to parent category level', () => {
|
|
268
|
+
const { records, sink } = captureSink();
|
|
269
|
+
const logger = createLogger({
|
|
270
|
+
level: 'error',
|
|
271
|
+
levels: { 'app.db': 'debug' },
|
|
272
|
+
name: 'app.db.queries',
|
|
273
|
+
sinks: [sink],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
logger.debug('query executed');
|
|
277
|
+
expect(records).toHaveLength(1);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { createConsoleSink, createFileSink } from '../sinks.js';
|
|
4
|
+
import type { LogFormatter, LogRecord } from '../types.js';
|
|
5
|
+
|
|
6
|
+
const makeRecord = (overrides?: Partial<LogRecord>): LogRecord => ({
|
|
7
|
+
category: 'app.test',
|
|
8
|
+
level: 'info',
|
|
9
|
+
message: 'test message',
|
|
10
|
+
metadata: {},
|
|
11
|
+
timestamp: new Date('2026-03-25T10:00:00.000Z'),
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const formattedSink = () =>
|
|
16
|
+
createConsoleSink({
|
|
17
|
+
formatter: { format: () => 'formatted' },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const parseJsonLines = async (
|
|
21
|
+
path: string
|
|
22
|
+
): Promise<Record<string, unknown>[]> => {
|
|
23
|
+
const content = await Bun.file(path).text();
|
|
24
|
+
return content
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// createConsoleSink
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
describe('createConsoleSink', () => {
|
|
35
|
+
const originalDebug = console.debug;
|
|
36
|
+
const originalInfo = console.info;
|
|
37
|
+
const originalWarn = console.warn;
|
|
38
|
+
const originalError = console.error;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// oxlint-disable-next-line no-empty-function
|
|
42
|
+
console.debug = mock(() => {
|
|
43
|
+
// noop
|
|
44
|
+
});
|
|
45
|
+
// oxlint-disable-next-line no-empty-function
|
|
46
|
+
console.info = mock(() => {
|
|
47
|
+
// noop
|
|
48
|
+
});
|
|
49
|
+
// oxlint-disable-next-line no-empty-function
|
|
50
|
+
console.warn = mock(() => {
|
|
51
|
+
// noop
|
|
52
|
+
});
|
|
53
|
+
// oxlint-disable-next-line no-empty-function
|
|
54
|
+
console.error = mock(() => {
|
|
55
|
+
// noop
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
console.debug = originalDebug;
|
|
61
|
+
console.info = originalInfo;
|
|
62
|
+
console.warn = originalWarn;
|
|
63
|
+
console.error = originalError;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('level routing', () => {
|
|
67
|
+
test('writes to console.debug for trace level', () => {
|
|
68
|
+
const sink = formattedSink();
|
|
69
|
+
sink.write(makeRecord({ level: 'trace' }));
|
|
70
|
+
expect(console.debug).toHaveBeenCalledWith('formatted');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('writes to console.debug for debug level', () => {
|
|
74
|
+
const sink = formattedSink();
|
|
75
|
+
sink.write(makeRecord({ level: 'debug' }));
|
|
76
|
+
expect(console.debug).toHaveBeenCalledWith('formatted');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('writes to console.info for info level', () => {
|
|
80
|
+
const sink = formattedSink();
|
|
81
|
+
sink.write(makeRecord({ level: 'info' }));
|
|
82
|
+
expect(console.info).toHaveBeenCalledWith('formatted');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('writes to console.warn for warn level', () => {
|
|
86
|
+
const sink = formattedSink();
|
|
87
|
+
sink.write(makeRecord({ level: 'warn' }));
|
|
88
|
+
expect(console.warn).toHaveBeenCalledWith('formatted');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('writes to console.error for error level', () => {
|
|
92
|
+
const sink = formattedSink();
|
|
93
|
+
sink.write(makeRecord({ level: 'error' }));
|
|
94
|
+
expect(console.error).toHaveBeenCalledWith('formatted');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('writes to console.error for fatal level', () => {
|
|
98
|
+
const sink = formattedSink();
|
|
99
|
+
sink.write(makeRecord({ level: 'fatal' }));
|
|
100
|
+
expect(console.error).toHaveBeenCalledWith('formatted');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('configuration', () => {
|
|
105
|
+
test('uses the provided formatter', () => {
|
|
106
|
+
const formatter: LogFormatter = {
|
|
107
|
+
format: (record) => `custom: ${record.message}`,
|
|
108
|
+
};
|
|
109
|
+
const sink = createConsoleSink({ formatter });
|
|
110
|
+
sink.write(makeRecord({ message: 'hello' }));
|
|
111
|
+
expect(console.info).toHaveBeenCalledWith('custom: hello');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("sink has name 'console'", () => {
|
|
115
|
+
const sink = createConsoleSink();
|
|
116
|
+
expect(sink.name).toBe('console');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// createFileSink
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe('createFileSink', () => {
|
|
126
|
+
test('appends records to the specified file', async () => {
|
|
127
|
+
const tmpPath = `/tmp/test-logging-sink-${Date.now()}.log`;
|
|
128
|
+
const sink = createFileSink({ path: tmpPath });
|
|
129
|
+
|
|
130
|
+
sink.write(makeRecord({ message: 'line 1' }));
|
|
131
|
+
sink.write(makeRecord({ message: 'line 2' }));
|
|
132
|
+
await sink.flush?.();
|
|
133
|
+
|
|
134
|
+
const lines = await parseJsonLines(tmpPath);
|
|
135
|
+
|
|
136
|
+
expect(lines).toHaveLength(2);
|
|
137
|
+
expect(lines[0]?.['message']).toBe('line 1');
|
|
138
|
+
expect(lines[1]?.['message']).toBe('line 2');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('flush completes pending writes', async () => {
|
|
142
|
+
const tmpPath = `/tmp/test-logging-flush-${Date.now()}.log`;
|
|
143
|
+
const sink = createFileSink({ path: tmpPath });
|
|
144
|
+
|
|
145
|
+
sink.write(makeRecord({ message: 'flushed' }));
|
|
146
|
+
await sink.flush?.();
|
|
147
|
+
|
|
148
|
+
const content = await Bun.file(tmpPath).text();
|
|
149
|
+
expect(content).toContain('flushed');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("sink has name 'file'", () => {
|
|
153
|
+
const tmpPath = `/tmp/test-logging-name-${Date.now()}.log`;
|
|
154
|
+
const sink = createFileSink({ path: tmpPath });
|
|
155
|
+
expect(sink.name).toBe('file');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('write receives a well-formed LogRecord', () => {
|
|
159
|
+
const tmpPath = `/tmp/test-logging-record-${Date.now()}.log`;
|
|
160
|
+
const formatter: LogFormatter = {
|
|
161
|
+
format: (record) => {
|
|
162
|
+
// Verify shape
|
|
163
|
+
expect(record.level).toBe('warn');
|
|
164
|
+
expect(record.message).toBe('check shape');
|
|
165
|
+
expect(record.category).toBe('test.shape');
|
|
166
|
+
expect(record.timestamp).toBeInstanceOf(Date);
|
|
167
|
+
expect(record.metadata).toEqual({ key: 'val' });
|
|
168
|
+
return 'ok';
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const sink = createFileSink({ formatter, path: tmpPath });
|
|
172
|
+
sink.write(
|
|
173
|
+
makeRecord({
|
|
174
|
+
category: 'test.shape',
|
|
175
|
+
level: 'warn',
|
|
176
|
+
message: 'check shape',
|
|
177
|
+
metadata: { key: 'val' },
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { LEVEL_PRIORITY } from './levels.js';
|
|
2
|
+
import type { LogLevel } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Valid log level set (for validation)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const VALID_LEVELS = new Set<string>(Object.keys(LEVEL_PRIORITY));
|
|
9
|
+
|
|
10
|
+
const isValidLogLevel = (value: string): value is LogLevel =>
|
|
11
|
+
VALID_LEVELS.has(value);
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// resolveLogLevel
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve log level from environment variables.
|
|
19
|
+
*
|
|
20
|
+
* 1. `TRAILS_LOG_LEVEL` -- explicit override (if valid).
|
|
21
|
+
* 2. `TRAILS_ENV` profile defaults:
|
|
22
|
+
* - `development` -> `"debug"`
|
|
23
|
+
* - `test` -> `undefined` (no logging by default)
|
|
24
|
+
* - `production` -> `undefined` (caller falls through to `"info"`)
|
|
25
|
+
* 3. `undefined` -- no env-based level configured.
|
|
26
|
+
*/
|
|
27
|
+
export const resolveLogLevel = (
|
|
28
|
+
env?: Record<string, string | undefined>
|
|
29
|
+
): LogLevel | undefined => {
|
|
30
|
+
const source = env ?? process.env;
|
|
31
|
+
|
|
32
|
+
// 1. Explicit override
|
|
33
|
+
const explicit = source['TRAILS_LOG_LEVEL'];
|
|
34
|
+
if (explicit !== undefined && isValidLogLevel(explicit)) {
|
|
35
|
+
return explicit;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 2. Profile defaults
|
|
39
|
+
const profile = source['TRAILS_ENV'];
|
|
40
|
+
if (profile === 'development') {
|
|
41
|
+
return 'debug';
|
|
42
|
+
}
|
|
43
|
+
if (profile === 'test') {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// production and everything else -- no opinion
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LogFormatter,
|
|
3
|
+
LogRecord,
|
|
4
|
+
PrettyFormatterOptions,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// JSON Formatter
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One JSON object per log record, newline-delimited.
|
|
13
|
+
* Metadata fields are flattened into the top-level object.
|
|
14
|
+
*/
|
|
15
|
+
export const createJsonFormatter = (): LogFormatter => ({
|
|
16
|
+
format(record: LogRecord): string {
|
|
17
|
+
const { level, message, category, timestamp, metadata } = record;
|
|
18
|
+
const obj: Record<string, unknown> = {
|
|
19
|
+
category,
|
|
20
|
+
level,
|
|
21
|
+
message,
|
|
22
|
+
timestamp: timestamp.toISOString(),
|
|
23
|
+
...metadata,
|
|
24
|
+
};
|
|
25
|
+
return JSON.stringify(obj);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Pretty Formatter
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const LEVEL_COLORS: Record<string, string> = {
|
|
34
|
+
debug: '\u001B[36m',
|
|
35
|
+
error: '\u001B[31m',
|
|
36
|
+
fatal: '\u001B[35m',
|
|
37
|
+
info: '\u001B[32m',
|
|
38
|
+
trace: '\u001B[90m',
|
|
39
|
+
warn: '\u001B[33m',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const RESET = '\u001B[0m';
|
|
43
|
+
|
|
44
|
+
const formatMetadata = (metadata: Record<string, unknown>): string => {
|
|
45
|
+
const entries = Object.entries(metadata);
|
|
46
|
+
if (entries.length === 0) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
return ` ${entries
|
|
50
|
+
.map(([k, v]) => {
|
|
51
|
+
const display = typeof v === 'string' ? v : JSON.stringify(v);
|
|
52
|
+
return `${k}=${display}`;
|
|
53
|
+
})
|
|
54
|
+
.join(' ')}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Human-readable formatter.
|
|
59
|
+
*
|
|
60
|
+
* Output: `10:00:00 INFO [app.entity] Entity created requestId=abc-123`
|
|
61
|
+
*/
|
|
62
|
+
const formatTimestamp = (timestamp: Date): string =>
|
|
63
|
+
`${timestamp.toISOString().slice(11, 19)} `;
|
|
64
|
+
|
|
65
|
+
const formatLevelAndMessage = (
|
|
66
|
+
level: string,
|
|
67
|
+
levelLabel: string,
|
|
68
|
+
category: string,
|
|
69
|
+
message: string,
|
|
70
|
+
useColors: boolean
|
|
71
|
+
): string => {
|
|
72
|
+
if (useColors) {
|
|
73
|
+
const color = LEVEL_COLORS[level] ?? '';
|
|
74
|
+
return `${color}${levelLabel}${RESET} [${category}] ${message}`;
|
|
75
|
+
}
|
|
76
|
+
return `${levelLabel} [${category}] ${message}`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const createPrettyFormatter = (
|
|
80
|
+
options?: PrettyFormatterOptions
|
|
81
|
+
): LogFormatter => {
|
|
82
|
+
const showTimestamps = options?.timestamps !== false;
|
|
83
|
+
const useColors = options?.colors ?? process.stdout?.isTTY === true;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
format(record: LogRecord): string {
|
|
87
|
+
const { level, message, category, timestamp, metadata } = record;
|
|
88
|
+
const levelLabel = level.toUpperCase().padEnd(5);
|
|
89
|
+
const meta = formatMetadata(metadata);
|
|
90
|
+
const prefix = showTimestamps ? formatTimestamp(timestamp) : '';
|
|
91
|
+
const body = formatLevelAndMessage(
|
|
92
|
+
level,
|
|
93
|
+
levelLabel,
|
|
94
|
+
category,
|
|
95
|
+
message,
|
|
96
|
+
useColors
|
|
97
|
+
);
|
|
98
|
+
return meta.length > 0 ? `${prefix}${body}${meta}` : `${prefix}${body}`;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Logger
|
|
2
|
+
export { createLogger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
// Re-exports from core
|
|
5
|
+
export type { Logger } from '@ontrails/core';
|
|
6
|
+
|
|
7
|
+
// Sinks and formatters
|
|
8
|
+
export { createConsoleSink, createFileSink } from './sinks.js';
|
|
9
|
+
export { createJsonFormatter, createPrettyFormatter } from './formatters.js';
|
|
10
|
+
|
|
11
|
+
// Level resolution
|
|
12
|
+
export { resolveLogLevel } from './env.js';
|
|
13
|
+
|
|
14
|
+
// Levels
|
|
15
|
+
export { LEVEL_PRIORITY, shouldLog, resolveCategory } from './levels.js';
|
|
16
|
+
|
|
17
|
+
// Types
|
|
18
|
+
export type {
|
|
19
|
+
LogLevel,
|
|
20
|
+
LogMetadata,
|
|
21
|
+
LogRecord,
|
|
22
|
+
LoggerConfig,
|
|
23
|
+
LogSink,
|
|
24
|
+
LogFormatter,
|
|
25
|
+
ConsoleSinkOptions,
|
|
26
|
+
FileSinkOptions,
|
|
27
|
+
PrettyFormatterOptions,
|
|
28
|
+
} from './types.js';
|
package/src/levels.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { LogLevel } from './types.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Level Priority
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
8
|
+
debug: 1,
|
|
9
|
+
error: 4,
|
|
10
|
+
fatal: 5,
|
|
11
|
+
info: 2,
|
|
12
|
+
silent: 6,
|
|
13
|
+
trace: 0,
|
|
14
|
+
warn: 3,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// shouldLog
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns true if a message at `messageLevel` should be emitted given the
|
|
23
|
+
* configured threshold `configuredLevel`.
|
|
24
|
+
*/
|
|
25
|
+
export const shouldLog = (
|
|
26
|
+
messageLevel: LogLevel,
|
|
27
|
+
configuredLevel: LogLevel
|
|
28
|
+
): boolean => LEVEL_PRIORITY[messageLevel] >= LEVEL_PRIORITY[configuredLevel];
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// resolveCategory
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk up the dot-separated category hierarchy to find the most specific
|
|
36
|
+
* configured level.
|
|
37
|
+
*
|
|
38
|
+
* Example: "app.db.queries" -> "app.db" -> "app" -> fallback
|
|
39
|
+
*/
|
|
40
|
+
/** Walk up the dot-separated hierarchy looking for a configured level. */
|
|
41
|
+
const findLevel = (
|
|
42
|
+
name: string,
|
|
43
|
+
levels: Record<string, LogLevel>
|
|
44
|
+
): LogLevel | undefined => {
|
|
45
|
+
let prefix = name;
|
|
46
|
+
while (prefix.length > 0) {
|
|
47
|
+
const level = levels[prefix];
|
|
48
|
+
if (level !== undefined) {
|
|
49
|
+
return level;
|
|
50
|
+
}
|
|
51
|
+
const lastDot = prefix.lastIndexOf('.');
|
|
52
|
+
if (lastDot === -1) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
prefix = prefix.slice(0, lastDot);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const resolveCategory = (
|
|
61
|
+
name: string,
|
|
62
|
+
levels: Record<string, LogLevel> | undefined,
|
|
63
|
+
fallback: LogLevel
|
|
64
|
+
): LogLevel => {
|
|
65
|
+
if (!levels) {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
return findLevel(name, levels) ?? fallback;
|
|
69
|
+
};
|