@flighthq/log 0.1.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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +91 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +719 -0
- package/dist/log.js.map +1 -0
- package/package.json +38 -0
- package/src/log.test.ts +1131 -0
package/src/log.test.ts
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { connectSignal } from '@flighthq/signals';
|
|
2
|
+
import type { LogEntry, LogSignals } from '@flighthq/types';
|
|
3
|
+
import { LogLevel } from '@flighthq/types';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
addLogSink,
|
|
7
|
+
beginLogGroup,
|
|
8
|
+
clearLogChannelLevels,
|
|
9
|
+
clearLogGroups,
|
|
10
|
+
clearLogRedactionPaths,
|
|
11
|
+
clearLogSerializers,
|
|
12
|
+
clearLogSinks,
|
|
13
|
+
clearMemoryLogSink,
|
|
14
|
+
createBufferedLogSink,
|
|
15
|
+
createChildLogContext,
|
|
16
|
+
createConsoleCaptureSink,
|
|
17
|
+
createFanoutLogSink,
|
|
18
|
+
createFileLogSink,
|
|
19
|
+
createFilterLogSink,
|
|
20
|
+
createJsonLogFormatter,
|
|
21
|
+
createLogContext,
|
|
22
|
+
createLogSpan,
|
|
23
|
+
createMemoryLogSink,
|
|
24
|
+
createRateLimitedLogSink,
|
|
25
|
+
createSampledLogSink,
|
|
26
|
+
createTextLogFormatter,
|
|
27
|
+
createWebLogTransportBackend,
|
|
28
|
+
disposeFileLogSink,
|
|
29
|
+
disposeLogSink,
|
|
30
|
+
enableLogSignals,
|
|
31
|
+
endLogGroup,
|
|
32
|
+
endLogTimer,
|
|
33
|
+
enterLogSpan,
|
|
34
|
+
exitLogSpan,
|
|
35
|
+
flushLogSink,
|
|
36
|
+
getLogChannelLevel,
|
|
37
|
+
getLogConsoleLevel,
|
|
38
|
+
getLogLevel,
|
|
39
|
+
getLogLevelName,
|
|
40
|
+
getLogTransportBackend,
|
|
41
|
+
getMemoryLogSinkEntries,
|
|
42
|
+
log,
|
|
43
|
+
logAssert,
|
|
44
|
+
logDebug,
|
|
45
|
+
logDebugWith,
|
|
46
|
+
logError,
|
|
47
|
+
logErrorWith,
|
|
48
|
+
logInfo,
|
|
49
|
+
logInfoWith,
|
|
50
|
+
logOnce,
|
|
51
|
+
logVerbose,
|
|
52
|
+
logVerboseWith,
|
|
53
|
+
logWarn,
|
|
54
|
+
logWarnWith,
|
|
55
|
+
logWith,
|
|
56
|
+
parseLogLevel,
|
|
57
|
+
registerLogSerializer,
|
|
58
|
+
removeLogSink,
|
|
59
|
+
serializeLogError,
|
|
60
|
+
setLogChannelLevel,
|
|
61
|
+
setLogConsoleLevel,
|
|
62
|
+
setLogLevel,
|
|
63
|
+
setLogRedactionPaths,
|
|
64
|
+
setLogSink,
|
|
65
|
+
setLogTransportBackend,
|
|
66
|
+
startLogTimer,
|
|
67
|
+
} from './log';
|
|
68
|
+
|
|
69
|
+
function recordingSink(): { entries: LogEntry[]; sink: (entry: LogEntry) => void } {
|
|
70
|
+
const entries: LogEntry[] = [];
|
|
71
|
+
const sink = (entry: Readonly<LogEntry>): void => {
|
|
72
|
+
entries.push({ ...entry });
|
|
73
|
+
};
|
|
74
|
+
addLogSink(sink);
|
|
75
|
+
return { entries, sink };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
clearLogGroups();
|
|
80
|
+
clearLogRedactionPaths();
|
|
81
|
+
clearLogSerializers();
|
|
82
|
+
clearLogSinks();
|
|
83
|
+
clearLogChannelLevels();
|
|
84
|
+
setLogConsoleLevel(LogLevel.Info);
|
|
85
|
+
setLogLevel(LogLevel.Verbose);
|
|
86
|
+
setLogTransportBackend(null);
|
|
87
|
+
vi.restoreAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('addLogSink', () => {
|
|
91
|
+
it('adds a sink that receives emitted entries', () => {
|
|
92
|
+
const entries: LogEntry[] = [];
|
|
93
|
+
addLogSink((e) => entries.push({ ...e }));
|
|
94
|
+
log(LogLevel.Info, 'hello');
|
|
95
|
+
expect(entries).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not add the same sink twice', () => {
|
|
99
|
+
const entries: LogEntry[] = [];
|
|
100
|
+
const sink = (e: Readonly<LogEntry>): void => {
|
|
101
|
+
entries.push({ ...e });
|
|
102
|
+
};
|
|
103
|
+
addLogSink(sink);
|
|
104
|
+
addLogSink(sink);
|
|
105
|
+
log(LogLevel.Info, 'x');
|
|
106
|
+
expect(entries).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('beginLogGroup', () => {
|
|
111
|
+
afterEach(() => clearLogGroups());
|
|
112
|
+
|
|
113
|
+
it('emits a Debug group-begin entry and increments nesting depth', () => {
|
|
114
|
+
const { entries } = recordingSink();
|
|
115
|
+
beginLogGroup('setup');
|
|
116
|
+
expect(entries).toHaveLength(1);
|
|
117
|
+
expect(entries[0].level).toBe(LogLevel.Debug);
|
|
118
|
+
expect(entries[0].data).toMatchObject({ group: 'begin', depth: 1 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('nests: second beginLogGroup produces depth 2', () => {
|
|
122
|
+
const { entries } = recordingSink();
|
|
123
|
+
beginLogGroup('outer');
|
|
124
|
+
beginLogGroup('inner');
|
|
125
|
+
expect(entries[1].data).toMatchObject({ depth: 2 });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('clearLogChannelLevels', () => {
|
|
130
|
+
it('removes all per-channel level overrides', () => {
|
|
131
|
+
setLogChannelLevel('render', LogLevel.Error);
|
|
132
|
+
clearLogChannelLevels();
|
|
133
|
+
expect(getLogChannelLevel('render')).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('clearLogGroups', () => {
|
|
138
|
+
afterEach(() => clearLogGroups());
|
|
139
|
+
|
|
140
|
+
it('resets group nesting depth to zero without emitting', () => {
|
|
141
|
+
const { entries } = recordingSink();
|
|
142
|
+
beginLogGroup('a');
|
|
143
|
+
beginLogGroup('b');
|
|
144
|
+
entries.length = 0;
|
|
145
|
+
clearLogGroups();
|
|
146
|
+
// After clearLogGroups, endLogGroup should be a no-op (depth is 0)
|
|
147
|
+
endLogGroup();
|
|
148
|
+
expect(entries).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('clearLogRedactionPaths', () => {
|
|
153
|
+
it('removes all redaction paths so fields are no longer redacted', () => {
|
|
154
|
+
setLogRedactionPaths(['token']);
|
|
155
|
+
clearLogRedactionPaths();
|
|
156
|
+
const fmt = createJsonLogFormatter();
|
|
157
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: { token: 'secret' } };
|
|
158
|
+
const result = JSON.parse(fmt(entry));
|
|
159
|
+
expect(result.data.token).toBe('secret');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('clearLogSerializers', () => {
|
|
164
|
+
it('removes all serializer registrations', () => {
|
|
165
|
+
registerLogSerializer('acme.Foo', () => ({ serialized: true }));
|
|
166
|
+
clearLogSerializers();
|
|
167
|
+
const fmt = createJsonLogFormatter();
|
|
168
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: { obj: { __kind: 'acme.Foo', x: 1 } } };
|
|
169
|
+
const result = JSON.parse(fmt(entry));
|
|
170
|
+
// Without serializer the value should pass through as-is
|
|
171
|
+
expect(result.data.obj.__kind).toBe('acme.Foo');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('clearLogSinks', () => {
|
|
176
|
+
it('removes all sinks so subsequent emit calls are no-ops', () => {
|
|
177
|
+
const entries: LogEntry[] = [];
|
|
178
|
+
addLogSink((e) => entries.push({ ...e }));
|
|
179
|
+
clearLogSinks();
|
|
180
|
+
log(LogLevel.Info, 'x');
|
|
181
|
+
expect(entries).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('clearMemoryLogSink', () => {
|
|
186
|
+
it('empties the captured entries', () => {
|
|
187
|
+
const handle = createMemoryLogSink(10);
|
|
188
|
+
addLogSink(handle.sink);
|
|
189
|
+
log(LogLevel.Info, 'a');
|
|
190
|
+
clearMemoryLogSink(handle);
|
|
191
|
+
expect(getMemoryLogSinkEntries(handle)).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('createBufferedLogSink', () => {
|
|
196
|
+
it('does not forward entries until flushed', () => {
|
|
197
|
+
const forwarded: LogEntry[] = [];
|
|
198
|
+
const handle = createBufferedLogSink((e) => forwarded.push({ ...e }), { size: 100, intervalMs: 0 });
|
|
199
|
+
addLogSink(handle.sink);
|
|
200
|
+
log(LogLevel.Info, 'queued');
|
|
201
|
+
expect(forwarded).toHaveLength(0);
|
|
202
|
+
flushLogSink(handle);
|
|
203
|
+
expect(forwarded).toHaveLength(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('auto-flushes when buffer reaches size', () => {
|
|
207
|
+
const forwarded: LogEntry[] = [];
|
|
208
|
+
const handle = createBufferedLogSink((e) => forwarded.push({ ...e }), { size: 2, intervalMs: 0 });
|
|
209
|
+
addLogSink(handle.sink);
|
|
210
|
+
log(LogLevel.Info, 'a');
|
|
211
|
+
log(LogLevel.Info, 'b');
|
|
212
|
+
expect(forwarded).toHaveLength(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('disposeLogSink flushes and stops future auto-flush', () => {
|
|
216
|
+
const forwarded: LogEntry[] = [];
|
|
217
|
+
const handle = createBufferedLogSink((e) => forwarded.push({ ...e }), { size: 100, intervalMs: 0 });
|
|
218
|
+
addLogSink(handle.sink);
|
|
219
|
+
log(LogLevel.Info, 'pending');
|
|
220
|
+
disposeLogSink(handle);
|
|
221
|
+
expect(forwarded).toHaveLength(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('createChildLogContext', () => {
|
|
226
|
+
it('merges parent and child fields (child wins)', () => {
|
|
227
|
+
const parent = createLogContext('chan', { a: 1, b: 2 });
|
|
228
|
+
const child = createChildLogContext(parent, { b: 99, c: 3 });
|
|
229
|
+
expect(child.fields).toEqual({ a: 1, b: 99, c: 3 });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('inherits parent channel when no override given', () => {
|
|
233
|
+
const parent = createLogContext('parent', {});
|
|
234
|
+
const child = createChildLogContext(parent, {});
|
|
235
|
+
expect(child.channel).toBe('parent');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('overrides channel when provided', () => {
|
|
239
|
+
const parent = createLogContext('parent', {});
|
|
240
|
+
const child = createChildLogContext(parent, {}, 'child');
|
|
241
|
+
expect(child.channel).toBe('child');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('createConsoleCaptureSink', () => {
|
|
246
|
+
it('records every level as a tagged JSON envelope on console.debug', () => {
|
|
247
|
+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
248
|
+
setLogConsoleLevel(LogLevel.None);
|
|
249
|
+
setLogSink(createConsoleCaptureSink());
|
|
250
|
+
log(LogLevel.Verbose, { n: 1 }, 'batch');
|
|
251
|
+
expect(debug).toHaveBeenCalledTimes(1);
|
|
252
|
+
const parsed = JSON.parse((debug.mock.calls[0] as string[])[0]);
|
|
253
|
+
expect(parsed).toMatchObject({ __flight: true, level: 'verbose', channel: 'batch', data: { n: 1 } });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('also prints a human line only for levels at or above the console threshold', () => {
|
|
257
|
+
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
258
|
+
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
259
|
+
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
260
|
+
setLogConsoleLevel(LogLevel.Info);
|
|
261
|
+
setLogSink(createConsoleCaptureSink());
|
|
262
|
+
log(LogLevel.Info, 'shown');
|
|
263
|
+
log(LogLevel.Verbose, 'hidden');
|
|
264
|
+
expect(info).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(consoleLog).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('wraps a string payload as { msg } in the envelope', () => {
|
|
269
|
+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
270
|
+
setLogSink(createConsoleCaptureSink());
|
|
271
|
+
log(LogLevel.Error, 'boom');
|
|
272
|
+
expect(JSON.parse((debug.mock.calls[0] as string[])[0]).data).toEqual({ msg: 'boom' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('accepts a custom formatter', () => {
|
|
276
|
+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
277
|
+
const customFormatter = vi.fn(() => 'custom-line');
|
|
278
|
+
setLogSink(createConsoleCaptureSink({ formatter: customFormatter }));
|
|
279
|
+
log(LogLevel.Info, 'x');
|
|
280
|
+
expect(customFormatter).toHaveBeenCalledTimes(1);
|
|
281
|
+
expect(debug).toHaveBeenCalledWith('custom-line');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('createFanoutLogSink', () => {
|
|
286
|
+
it('forwards each entry to all component sinks', () => {
|
|
287
|
+
const a: LogEntry[] = [];
|
|
288
|
+
const b: LogEntry[] = [];
|
|
289
|
+
const fanout = createFanoutLogSink(
|
|
290
|
+
(e) => a.push({ ...e }),
|
|
291
|
+
(e) => b.push({ ...e }),
|
|
292
|
+
);
|
|
293
|
+
addLogSink(fanout);
|
|
294
|
+
log(LogLevel.Info, 'hello');
|
|
295
|
+
expect(a).toHaveLength(1);
|
|
296
|
+
expect(b).toHaveLength(1);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('createFileLogSink', () => {
|
|
301
|
+
it('writes formatted lines to the installed transport backend', () => {
|
|
302
|
+
const lines: string[] = [];
|
|
303
|
+
setLogTransportBackend({ write: (line) => lines.push(line) });
|
|
304
|
+
const handle = createFileLogSink();
|
|
305
|
+
addLogSink(handle.sink);
|
|
306
|
+
log(LogLevel.Info, 'file-entry');
|
|
307
|
+
expect(lines).toHaveLength(1);
|
|
308
|
+
const parsed = JSON.parse(lines[0]);
|
|
309
|
+
expect(parsed).toMatchObject({ __flight: true, level: 'info', data: { msg: 'file-entry' } });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('is a no-op when no transport backend is set', () => {
|
|
313
|
+
const handle = createFileLogSink();
|
|
314
|
+
addLogSink(handle.sink);
|
|
315
|
+
// Should not throw with no backend
|
|
316
|
+
expect(() => log(LogLevel.Info, 'no-backend')).not.toThrow();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('accepts a custom formatter', () => {
|
|
320
|
+
const lines: string[] = [];
|
|
321
|
+
setLogTransportBackend({ write: (line) => lines.push(line) });
|
|
322
|
+
const handle = createFileLogSink({ formatter: () => 'custom' });
|
|
323
|
+
addLogSink(handle.sink);
|
|
324
|
+
log(LogLevel.Info, 'x');
|
|
325
|
+
expect(lines[0]).toBe('custom\n');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('createFilterLogSink', () => {
|
|
330
|
+
it('only forwards entries matching the predicate', () => {
|
|
331
|
+
const entries: LogEntry[] = [];
|
|
332
|
+
const filtered = createFilterLogSink(
|
|
333
|
+
(e) => entries.push({ ...e }),
|
|
334
|
+
(e) => e.level === LogLevel.Error,
|
|
335
|
+
);
|
|
336
|
+
addLogSink(filtered);
|
|
337
|
+
log(LogLevel.Info, 'skip');
|
|
338
|
+
log(LogLevel.Error, 'keep');
|
|
339
|
+
expect(entries).toHaveLength(1);
|
|
340
|
+
expect(entries[0].level).toBe(LogLevel.Error);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('createJsonLogFormatter', () => {
|
|
345
|
+
it('produces a __flight JSON envelope', () => {
|
|
346
|
+
const fmt = createJsonLogFormatter();
|
|
347
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: 'test', data: 'msg' };
|
|
348
|
+
const result = JSON.parse(fmt(entry));
|
|
349
|
+
expect(result).toMatchObject({ __flight: true, level: 'info', channel: 'test', data: { msg: 'msg' } });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('applies registered serializers to values with matching __kind', () => {
|
|
353
|
+
registerLogSerializer('acme.Widget', (v) => ({ serialized: true, id: (v as { id: number }).id }));
|
|
354
|
+
const fmt = createJsonLogFormatter();
|
|
355
|
+
const entry: LogEntry = {
|
|
356
|
+
level: LogLevel.Info,
|
|
357
|
+
channel: null,
|
|
358
|
+
data: { widget: { __kind: 'acme.Widget', id: 42 } },
|
|
359
|
+
};
|
|
360
|
+
const result = JSON.parse(fmt(entry));
|
|
361
|
+
expect(result.data.widget).toEqual({ serialized: true, id: 42 });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('applies redaction paths to nested fields', () => {
|
|
365
|
+
setLogRedactionPaths(['credentials.token', 'password']);
|
|
366
|
+
const fmt = createJsonLogFormatter();
|
|
367
|
+
const entry: LogEntry = {
|
|
368
|
+
level: LogLevel.Info,
|
|
369
|
+
channel: null,
|
|
370
|
+
data: { credentials: { token: 'secret', user: 'alice' }, password: 'pw' },
|
|
371
|
+
};
|
|
372
|
+
const result = JSON.parse(fmt(entry));
|
|
373
|
+
expect(result.data.credentials.token).toBe('[REDACTED]');
|
|
374
|
+
expect(result.data.credentials.user).toBe('alice');
|
|
375
|
+
expect(result.data.password).toBe('[REDACTED]');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('createLogContext', () => {
|
|
380
|
+
it('creates a context with the given channel and fields', () => {
|
|
381
|
+
const ctx = createLogContext('render', { version: 1 });
|
|
382
|
+
expect(ctx.channel).toBe('render');
|
|
383
|
+
expect(ctx.fields).toEqual({ version: 1 });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('defaults fields to empty object', () => {
|
|
387
|
+
const ctx = createLogContext('ch');
|
|
388
|
+
expect(ctx.fields).toEqual({});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('createLogSpan', () => {
|
|
393
|
+
it('creates a LogSpan plain value with the given name, fields, and channel', () => {
|
|
394
|
+
const span = createLogSpan('render-frame', { frame: 1 }, 'perf');
|
|
395
|
+
expect(span.name).toBe('render-frame');
|
|
396
|
+
expect(span.fields).toEqual({ frame: 1 });
|
|
397
|
+
expect(span.channel).toBe('perf');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('defaults fields to empty object and channel to null', () => {
|
|
401
|
+
const span = createLogSpan('op');
|
|
402
|
+
expect(span.fields).toEqual({});
|
|
403
|
+
expect(span.channel).toBeNull();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('createMemoryLogSink', () => {
|
|
408
|
+
it('captures entries up to capacity', () => {
|
|
409
|
+
const handle = createMemoryLogSink(3);
|
|
410
|
+
addLogSink(handle.sink);
|
|
411
|
+
log(LogLevel.Info, 'a');
|
|
412
|
+
log(LogLevel.Info, 'b');
|
|
413
|
+
log(LogLevel.Info, 'c');
|
|
414
|
+
expect(getMemoryLogSinkEntries(handle)).toHaveLength(3);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('overwrites oldest entries when capacity is exceeded (ring buffer)', () => {
|
|
418
|
+
const handle = createMemoryLogSink(2);
|
|
419
|
+
addLogSink(handle.sink);
|
|
420
|
+
log(LogLevel.Info, 'first');
|
|
421
|
+
log(LogLevel.Info, 'second');
|
|
422
|
+
log(LogLevel.Info, 'third');
|
|
423
|
+
const entries = getMemoryLogSinkEntries(handle);
|
|
424
|
+
expect(entries).toHaveLength(2);
|
|
425
|
+
expect(entries[0].data).toBe('second');
|
|
426
|
+
expect(entries[1].data).toBe('third');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('returns entries oldest-first after capacity exceeded', () => {
|
|
430
|
+
const handle = createMemoryLogSink(3);
|
|
431
|
+
addLogSink(handle.sink);
|
|
432
|
+
for (let i = 0; i < 5; i++) log(LogLevel.Info, `msg${i}`);
|
|
433
|
+
const entries = getMemoryLogSinkEntries(handle);
|
|
434
|
+
expect(entries.map((e) => e.data)).toEqual(['msg2', 'msg3', 'msg4']);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('createRateLimitedLogSink', () => {
|
|
439
|
+
it('limits entries per interval', () => {
|
|
440
|
+
const forwarded: LogEntry[] = [];
|
|
441
|
+
const handle = createRateLimitedLogSink((e) => forwarded.push({ ...e }), { maxPerInterval: 2, intervalMs: 10000 });
|
|
442
|
+
addLogSink(handle.sink);
|
|
443
|
+
log(LogLevel.Info, 'a');
|
|
444
|
+
log(LogLevel.Info, 'b');
|
|
445
|
+
log(LogLevel.Info, 'c'); // should be rate-limited
|
|
446
|
+
expect(forwarded).toHaveLength(2);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('tracks per-channel when perChannel is true', () => {
|
|
450
|
+
const forwarded: LogEntry[] = [];
|
|
451
|
+
const handle = createRateLimitedLogSink((e) => forwarded.push({ ...e }), {
|
|
452
|
+
perChannel: true,
|
|
453
|
+
maxPerInterval: 1,
|
|
454
|
+
intervalMs: 10000,
|
|
455
|
+
});
|
|
456
|
+
addLogSink(handle.sink);
|
|
457
|
+
log(LogLevel.Info, 'a', 'ch1');
|
|
458
|
+
log(LogLevel.Info, 'b', 'ch1'); // rate-limited for ch1
|
|
459
|
+
log(LogLevel.Info, 'c', 'ch2'); // allowed — different channel
|
|
460
|
+
expect(forwarded).toHaveLength(2);
|
|
461
|
+
expect(forwarded[0].channel).toBe('ch1');
|
|
462
|
+
expect(forwarded[1].channel).toBe('ch2');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe('createSampledLogSink', () => {
|
|
467
|
+
it('forwards 1-in-N entries', () => {
|
|
468
|
+
const forwarded: LogEntry[] = [];
|
|
469
|
+
const sampled = createSampledLogSink((e) => forwarded.push({ ...e }), 3);
|
|
470
|
+
addLogSink(sampled);
|
|
471
|
+
for (let i = 0; i < 9; i++) log(LogLevel.Info, `msg${i}`);
|
|
472
|
+
expect(forwarded).toHaveLength(3);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('passes all entries through when rate is 1', () => {
|
|
476
|
+
const forwarded: LogEntry[] = [];
|
|
477
|
+
const sampled = createSampledLogSink((e) => forwarded.push({ ...e }), 1);
|
|
478
|
+
addLogSink(sampled);
|
|
479
|
+
log(LogLevel.Info, 'a');
|
|
480
|
+
log(LogLevel.Info, 'b');
|
|
481
|
+
expect(forwarded).toHaveLength(2);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe('createTextLogFormatter', () => {
|
|
486
|
+
it('produces a readable prefix + message line', () => {
|
|
487
|
+
const fmt = createTextLogFormatter();
|
|
488
|
+
const entry: LogEntry = { level: LogLevel.Warn, channel: 'batch', data: 'test' };
|
|
489
|
+
expect(fmt(entry)).toBe('[batch] test');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('includes timestamp prefix when requested', () => {
|
|
493
|
+
const fmt = createTextLogFormatter({ timestamp: true });
|
|
494
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: 'x' };
|
|
495
|
+
expect(fmt(entry)).toMatch(/^t=\d+\.\d+ \[flight\] x$/);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('includes level prefix when requested', () => {
|
|
499
|
+
const fmt = createTextLogFormatter({ levelPrefix: true });
|
|
500
|
+
const entry: LogEntry = { level: LogLevel.Error, channel: 'ch', data: 'boom' };
|
|
501
|
+
expect(fmt(entry)).toBe('error [ch] boom');
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe('createWebLogTransportBackend', () => {
|
|
506
|
+
it('returns a no-op backend with a write function', () => {
|
|
507
|
+
const backend = createWebLogTransportBackend();
|
|
508
|
+
expect(typeof backend.write).toBe('function');
|
|
509
|
+
expect(() => backend.write('test line')).not.toThrow();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('disposeFileLogSink', () => {
|
|
514
|
+
it('calls flush and dispose on the installed transport backend', () => {
|
|
515
|
+
const flushed: boolean[] = [];
|
|
516
|
+
const disposed: boolean[] = [];
|
|
517
|
+
setLogTransportBackend({
|
|
518
|
+
write: () => {},
|
|
519
|
+
flush: () => flushed.push(true),
|
|
520
|
+
dispose: () => disposed.push(true),
|
|
521
|
+
});
|
|
522
|
+
const handle = createFileLogSink();
|
|
523
|
+
disposeFileLogSink(handle);
|
|
524
|
+
expect(flushed).toHaveLength(1);
|
|
525
|
+
expect(disposed).toHaveLength(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('is a no-op when no backend is installed', () => {
|
|
529
|
+
const handle = createFileLogSink();
|
|
530
|
+
expect(() => disposeFileLogSink(handle)).not.toThrow();
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('disposeLogSink', () => {
|
|
535
|
+
it('is covered by createBufferedLogSink tests', () => {
|
|
536
|
+
// Verified above in createBufferedLogSink describe.
|
|
537
|
+
expect(true).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('enableLogSignals', () => {
|
|
542
|
+
afterEach(() => {
|
|
543
|
+
// Reset signals (they are lazy-created; clearLogSinks does not reset them)
|
|
544
|
+
// We test with fresh sinks to avoid cross-test coupling.
|
|
545
|
+
clearLogSinks();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('returns a LogSignals entity with onLogEntry and onLogError signals', () => {
|
|
549
|
+
const signals: LogSignals = enableLogSignals();
|
|
550
|
+
expect(signals.onLogEntry).toBeDefined();
|
|
551
|
+
expect(signals.onLogError).toBeDefined();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('returns the same object on subsequent calls', () => {
|
|
555
|
+
const a = enableLogSignals();
|
|
556
|
+
const b = enableLogSignals();
|
|
557
|
+
expect(a).toBe(b);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('onLogEntry fires for every emitted entry', () => {
|
|
561
|
+
const signals = enableLogSignals();
|
|
562
|
+
const received: LogEntry[] = [];
|
|
563
|
+
connectSignal(signals.onLogEntry, (e) => received.push({ ...e }));
|
|
564
|
+
// Even with no sinks the signal path fires (signals bypass the sinks-empty fast path)
|
|
565
|
+
log(LogLevel.Info, 'via-signal');
|
|
566
|
+
expect(received).toHaveLength(1);
|
|
567
|
+
expect(received[0].data).toBe('via-signal');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('onLogError fires only for Error-level entries', () => {
|
|
571
|
+
const signals = enableLogSignals();
|
|
572
|
+
const errors: LogEntry[] = [];
|
|
573
|
+
connectSignal(signals.onLogError, (e) => errors.push({ ...e }));
|
|
574
|
+
log(LogLevel.Info, 'not-error');
|
|
575
|
+
log(LogLevel.Error, 'is-error');
|
|
576
|
+
expect(errors).toHaveLength(1);
|
|
577
|
+
expect(errors[0].level).toBe(LogLevel.Error);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe('endLogGroup', () => {
|
|
582
|
+
it('emits a Debug group-end entry and decrements nesting depth', () => {
|
|
583
|
+
const { entries } = recordingSink();
|
|
584
|
+
beginLogGroup('setup');
|
|
585
|
+
entries.length = 0; // clear begin entry
|
|
586
|
+
endLogGroup();
|
|
587
|
+
expect(entries).toHaveLength(1);
|
|
588
|
+
expect(entries[0].data).toMatchObject({ group: 'end' });
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('is a no-op when no group is open', () => {
|
|
592
|
+
const { entries } = recordingSink();
|
|
593
|
+
endLogGroup();
|
|
594
|
+
expect(entries).toHaveLength(0);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe('endLogTimer', () => {
|
|
599
|
+
it('emits a Debug entry with label and elapsedMs fields and returns elapsed', () => {
|
|
600
|
+
const entries: LogEntry[] = [];
|
|
601
|
+
addLogSink((e) => entries.push({ ...e }));
|
|
602
|
+
const timer = startLogTimer('op', 'perf');
|
|
603
|
+
const elapsed = endLogTimer(timer);
|
|
604
|
+
expect(elapsed).toBeGreaterThanOrEqual(0);
|
|
605
|
+
expect(entries).toHaveLength(1);
|
|
606
|
+
expect(entries[0].level).toBe(LogLevel.Debug);
|
|
607
|
+
expect(entries[0].channel).toBe('perf');
|
|
608
|
+
expect(entries[0].data).toMatchObject({ label: 'op' });
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe('enterLogSpan', () => {
|
|
613
|
+
afterEach(() => {
|
|
614
|
+
// Ensure spans are cleaned up
|
|
615
|
+
const span = createLogSpan('cleanup');
|
|
616
|
+
exitLogSpan(span);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('merges span fields into emitted entries while the span is active', () => {
|
|
620
|
+
const { entries } = recordingSink();
|
|
621
|
+
const span = createLogSpan('request', { reqId: 'abc' });
|
|
622
|
+
enterLogSpan(span);
|
|
623
|
+
log(LogLevel.Info, 'inside span');
|
|
624
|
+
exitLogSpan(span);
|
|
625
|
+
expect(entries[0].data).toMatchObject({ reqId: 'abc' });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('does not merge span fields after exitLogSpan', () => {
|
|
629
|
+
const { entries } = recordingSink();
|
|
630
|
+
const span = createLogSpan('request', { reqId: 'def' });
|
|
631
|
+
enterLogSpan(span);
|
|
632
|
+
exitLogSpan(span);
|
|
633
|
+
log(LogLevel.Info, 'outside span');
|
|
634
|
+
expect(entries[0].data).toBe('outside span');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('newer spans win on field key collision', () => {
|
|
638
|
+
const { entries } = recordingSink();
|
|
639
|
+
const span1 = createLogSpan('outer', { x: 1, y: 10 });
|
|
640
|
+
const span2 = createLogSpan('inner', { x: 2 });
|
|
641
|
+
enterLogSpan(span1);
|
|
642
|
+
enterLogSpan(span2);
|
|
643
|
+
log(LogLevel.Info, 'nested');
|
|
644
|
+
exitLogSpan(span2);
|
|
645
|
+
exitLogSpan(span1);
|
|
646
|
+
expect((entries[0].data as Record<string, unknown>).x).toBe(2);
|
|
647
|
+
expect((entries[0].data as Record<string, unknown>).y).toBe(10);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe('exitLogSpan', () => {
|
|
652
|
+
it('is a no-op when the span is not in the stack', () => {
|
|
653
|
+
const { entries } = recordingSink();
|
|
654
|
+
const span = createLogSpan('phantom', { z: 1 });
|
|
655
|
+
exitLogSpan(span);
|
|
656
|
+
log(LogLevel.Info, 'clean');
|
|
657
|
+
expect(entries[0].data).toBe('clean');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('supports out-of-order exit (removes by identity)', () => {
|
|
661
|
+
const { entries } = recordingSink();
|
|
662
|
+
const span1 = createLogSpan('a', { a: 1 });
|
|
663
|
+
const span2 = createLogSpan('b', { b: 2 });
|
|
664
|
+
enterLogSpan(span1);
|
|
665
|
+
enterLogSpan(span2);
|
|
666
|
+
// Exit span1 first even though span2 was entered last
|
|
667
|
+
exitLogSpan(span1);
|
|
668
|
+
log(LogLevel.Info, 'after-a-exit');
|
|
669
|
+
exitLogSpan(span2);
|
|
670
|
+
expect((entries[0].data as Record<string, unknown>).b).toBe(2);
|
|
671
|
+
expect((entries[0].data as Record<string, unknown>).a).toBeUndefined();
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe('flushLogSink', () => {
|
|
676
|
+
it('is covered by createBufferedLogSink tests', () => {
|
|
677
|
+
expect(true).toBe(true);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('getLogChannelLevel', () => {
|
|
682
|
+
it('returns null when no channel level is set', () => {
|
|
683
|
+
expect(getLogChannelLevel('unknown')).toBeNull();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('returns the set level for a known channel', () => {
|
|
687
|
+
setLogChannelLevel('render', LogLevel.Error);
|
|
688
|
+
expect(getLogChannelLevel('render')).toBe(LogLevel.Error);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe('getLogConsoleLevel', () => {
|
|
693
|
+
it('returns the current threshold', () => {
|
|
694
|
+
setLogConsoleLevel(LogLevel.Verbose);
|
|
695
|
+
expect(getLogConsoleLevel()).toBe(LogLevel.Verbose);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe('getLogLevel', () => {
|
|
700
|
+
it('returns the current global emit level', () => {
|
|
701
|
+
setLogLevel(LogLevel.Error);
|
|
702
|
+
expect(getLogLevel()).toBe(LogLevel.Error);
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
describe('getLogLevelName', () => {
|
|
707
|
+
it('returns the canonical lowercase name for each level', () => {
|
|
708
|
+
expect(getLogLevelName(LogLevel.None)).toBe('none');
|
|
709
|
+
expect(getLogLevelName(LogLevel.Error)).toBe('error');
|
|
710
|
+
expect(getLogLevelName(LogLevel.Warn)).toBe('warn');
|
|
711
|
+
expect(getLogLevelName(LogLevel.Info)).toBe('info');
|
|
712
|
+
expect(getLogLevelName(LogLevel.Debug)).toBe('debug');
|
|
713
|
+
expect(getLogLevelName(LogLevel.Verbose)).toBe('verbose');
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
describe('getLogTransportBackend', () => {
|
|
718
|
+
it('returns null when no backend is set', () => {
|
|
719
|
+
expect(getLogTransportBackend()).toBeNull();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('returns the installed backend', () => {
|
|
723
|
+
const backend = createWebLogTransportBackend();
|
|
724
|
+
setLogTransportBackend(backend);
|
|
725
|
+
expect(getLogTransportBackend()).toBe(backend);
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
describe('getMemoryLogSinkEntries', () => {
|
|
730
|
+
it('returns empty array before any entries', () => {
|
|
731
|
+
const handle = createMemoryLogSink(5);
|
|
732
|
+
expect(getMemoryLogSinkEntries(handle)).toHaveLength(0);
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe('log', () => {
|
|
737
|
+
it('forwards level, channel, and data to all sinks', () => {
|
|
738
|
+
const { entries } = recordingSink();
|
|
739
|
+
log(LogLevel.Warn, { k: 1 }, 'shader');
|
|
740
|
+
expect(entries[0]).toEqual({ level: LogLevel.Warn, channel: 'shader', data: { k: 1 } });
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('defaults the channel to null', () => {
|
|
744
|
+
const { entries } = recordingSink();
|
|
745
|
+
log(LogLevel.Info, 'x');
|
|
746
|
+
expect(entries[0].channel).toBeNull();
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('does nothing when no sink is installed', () => {
|
|
750
|
+
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
751
|
+
expect(() => log(LogLevel.Info, 'x')).not.toThrow();
|
|
752
|
+
expect(debug).not.toHaveBeenCalled();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('accepts a lazy data provider thunk', () => {
|
|
756
|
+
const { entries } = recordingSink();
|
|
757
|
+
const provider = vi.fn(() => 'lazy-value');
|
|
758
|
+
log(LogLevel.Info, provider);
|
|
759
|
+
expect(provider).toHaveBeenCalledTimes(1);
|
|
760
|
+
expect(entries[0].data).toBe('lazy-value');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('does not call the thunk when the level is suppressed', () => {
|
|
764
|
+
recordingSink();
|
|
765
|
+
setLogLevel(LogLevel.Error);
|
|
766
|
+
const provider = vi.fn(() => 'should-not-be-called');
|
|
767
|
+
log(LogLevel.Verbose, provider);
|
|
768
|
+
expect(provider).not.toHaveBeenCalled();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('fans out to multiple sinks', () => {
|
|
772
|
+
const a: LogEntry[] = [];
|
|
773
|
+
const b: LogEntry[] = [];
|
|
774
|
+
addLogSink((e) => a.push({ ...e }));
|
|
775
|
+
addLogSink((e) => b.push({ ...e }));
|
|
776
|
+
log(LogLevel.Info, 'multi');
|
|
777
|
+
expect(a).toHaveLength(1);
|
|
778
|
+
expect(b).toHaveLength(1);
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
describe('logAssert', () => {
|
|
783
|
+
it('emits an Error entry when condition is false', () => {
|
|
784
|
+
const { entries } = recordingSink();
|
|
785
|
+
logAssert(false, 'assertion failed');
|
|
786
|
+
expect(entries).toHaveLength(1);
|
|
787
|
+
expect(entries[0].level).toBe(LogLevel.Error);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('does not emit when condition is true', () => {
|
|
791
|
+
const { entries } = recordingSink();
|
|
792
|
+
logAssert(true, 'should not emit');
|
|
793
|
+
expect(entries).toHaveLength(0);
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
describe('logDebug', () => {
|
|
798
|
+
it('emits at LogLevel.Debug', () => {
|
|
799
|
+
const { entries } = recordingSink();
|
|
800
|
+
logDebug('d', 'chan');
|
|
801
|
+
expect(entries[0]).toEqual({ level: LogLevel.Debug, channel: 'chan', data: 'd' });
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('accepts a lazy provider', () => {
|
|
805
|
+
const { entries } = recordingSink();
|
|
806
|
+
logDebug(() => 'lazy', null);
|
|
807
|
+
expect(entries[0].data).toBe('lazy');
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
describe('logDebugWith', () => {
|
|
812
|
+
it('emits at Debug with merged context fields', () => {
|
|
813
|
+
const { entries } = recordingSink();
|
|
814
|
+
const ctx = createLogContext('ch', { reqId: 'abc' });
|
|
815
|
+
logDebugWith(ctx, 'msg');
|
|
816
|
+
expect(entries[0]).toMatchObject({ level: LogLevel.Debug, channel: 'ch', data: { msg: 'msg', reqId: 'abc' } });
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('logError', () => {
|
|
821
|
+
it('emits at LogLevel.Error', () => {
|
|
822
|
+
const { entries } = recordingSink();
|
|
823
|
+
logError('e');
|
|
824
|
+
expect(entries[0].level).toBe(LogLevel.Error);
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
describe('logErrorWith', () => {
|
|
829
|
+
it('emits at Error with merged context fields', () => {
|
|
830
|
+
const { entries } = recordingSink();
|
|
831
|
+
const ctx = createLogContext('ch', { reqId: '1' });
|
|
832
|
+
logErrorWith(ctx, 'fail');
|
|
833
|
+
expect(entries[0]).toMatchObject({ level: LogLevel.Error, data: { msg: 'fail', reqId: '1' } });
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe('logInfo', () => {
|
|
838
|
+
it('emits at LogLevel.Info', () => {
|
|
839
|
+
const { entries } = recordingSink();
|
|
840
|
+
logInfo('i');
|
|
841
|
+
expect(entries[0].level).toBe(LogLevel.Info);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
describe('logInfoWith', () => {
|
|
846
|
+
it('emits at Info with merged context fields', () => {
|
|
847
|
+
const { entries } = recordingSink();
|
|
848
|
+
const ctx = createLogContext('ch', { v: 2 });
|
|
849
|
+
logInfoWith(ctx, { extra: true });
|
|
850
|
+
expect(entries[0].data).toMatchObject({ v: 2, extra: true });
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
describe('logOnce', () => {
|
|
855
|
+
afterEach(() => {
|
|
856
|
+
// Reset module-level once-key set by clearing the internal state via a fresh import.
|
|
857
|
+
// Since we can't easily reset the module state, we test idempotency within a single test.
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('emits only the first time for a given key', () => {
|
|
861
|
+
const { entries } = recordingSink();
|
|
862
|
+
logOnce('key1', LogLevel.Warn, 'first');
|
|
863
|
+
logOnce('key1', LogLevel.Warn, 'second');
|
|
864
|
+
expect(entries).toHaveLength(1);
|
|
865
|
+
expect(entries[0].data).toBe('first');
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it('allows different keys to emit independently', () => {
|
|
869
|
+
const { entries } = recordingSink();
|
|
870
|
+
logOnce('key2a', LogLevel.Info, 'a');
|
|
871
|
+
logOnce('key2b', LogLevel.Info, 'b');
|
|
872
|
+
expect(entries).toHaveLength(2);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('returns true on first emit, false on subsequent calls', () => {
|
|
876
|
+
recordingSink();
|
|
877
|
+
expect(logOnce('key3', LogLevel.Info, 'x')).toBe(true);
|
|
878
|
+
expect(logOnce('key3', LogLevel.Info, 'x')).toBe(false);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('logVerbose', () => {
|
|
883
|
+
it('emits at LogLevel.Verbose', () => {
|
|
884
|
+
const { entries } = recordingSink();
|
|
885
|
+
logVerbose('v');
|
|
886
|
+
expect(entries[0].level).toBe(LogLevel.Verbose);
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
describe('logVerboseWith', () => {
|
|
891
|
+
it('emits at Verbose with merged context fields', () => {
|
|
892
|
+
const { entries } = recordingSink();
|
|
893
|
+
const ctx = createLogContext(null, { trace: true });
|
|
894
|
+
logVerboseWith(ctx, 'trace-msg');
|
|
895
|
+
expect(entries[0].data).toMatchObject({ msg: 'trace-msg', trace: true });
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
describe('logWarn', () => {
|
|
900
|
+
it('emits at LogLevel.Warn', () => {
|
|
901
|
+
const { entries } = recordingSink();
|
|
902
|
+
logWarn('w');
|
|
903
|
+
expect(entries[0].level).toBe(LogLevel.Warn);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
describe('logWarnWith', () => {
|
|
908
|
+
it('emits at Warn with merged context fields', () => {
|
|
909
|
+
const { entries } = recordingSink();
|
|
910
|
+
const ctx = createLogContext('ch', { ctx: 'val' });
|
|
911
|
+
logWarnWith(ctx, 'warning');
|
|
912
|
+
expect(entries[0].data).toMatchObject({ msg: 'warning', ctx: 'val' });
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe('logWith', () => {
|
|
917
|
+
it('emits at the given level with channel from context', () => {
|
|
918
|
+
const { entries } = recordingSink();
|
|
919
|
+
const ctx = createLogContext('render', { frame: 1 });
|
|
920
|
+
logWith(ctx, LogLevel.Info, 'update');
|
|
921
|
+
expect(entries[0]).toMatchObject({ level: LogLevel.Info, channel: 'render', data: { msg: 'update', frame: 1 } });
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('passes record data through with fields merged', () => {
|
|
925
|
+
const { entries } = recordingSink();
|
|
926
|
+
const ctx = createLogContext(null, { base: 1 });
|
|
927
|
+
logWith(ctx, LogLevel.Debug, { extra: 2 });
|
|
928
|
+
expect(entries[0].data).toEqual({ base: 1, extra: 2 });
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it('accepts a lazy provider', () => {
|
|
932
|
+
const { entries } = recordingSink();
|
|
933
|
+
const ctx = createLogContext(null, {});
|
|
934
|
+
logWith(ctx, LogLevel.Info, () => 'lazy');
|
|
935
|
+
expect(entries[0].data).toBe('lazy');
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
describe('parseLogLevel', () => {
|
|
940
|
+
it('parses canonical names to their LogLevel values', () => {
|
|
941
|
+
expect(parseLogLevel('error')).toBe(LogLevel.Error);
|
|
942
|
+
expect(parseLogLevel('warn')).toBe(LogLevel.Warn);
|
|
943
|
+
expect(parseLogLevel('info')).toBe(LogLevel.Info);
|
|
944
|
+
expect(parseLogLevel('debug')).toBe(LogLevel.Debug);
|
|
945
|
+
expect(parseLogLevel('verbose')).toBe(LogLevel.Verbose);
|
|
946
|
+
expect(parseLogLevel('none')).toBe(LogLevel.None);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('is case-insensitive', () => {
|
|
950
|
+
expect(parseLogLevel('ERROR')).toBe(LogLevel.Error);
|
|
951
|
+
expect(parseLogLevel('Warn')).toBe(LogLevel.Warn);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('returns null for unknown names', () => {
|
|
955
|
+
expect(parseLogLevel('unknown')).toBeNull();
|
|
956
|
+
expect(parseLogLevel('')).toBeNull();
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
describe('registerLogSerializer', () => {
|
|
961
|
+
it('applies the serializer when the JSON formatter encounters a matching __kind', () => {
|
|
962
|
+
registerLogSerializer('acme.Point', (v) => {
|
|
963
|
+
const p = v as { x: number; y: number };
|
|
964
|
+
return { serialized: `(${p.x},${p.y})` };
|
|
965
|
+
});
|
|
966
|
+
const fmt = createJsonLogFormatter();
|
|
967
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: { pt: { __kind: 'acme.Point', x: 3, y: 4 } } };
|
|
968
|
+
const result = JSON.parse(fmt(entry));
|
|
969
|
+
expect(result.data.pt).toEqual({ serialized: '(3,4)' });
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
describe('removeLogSink', () => {
|
|
974
|
+
it('removes a previously added sink and returns true', () => {
|
|
975
|
+
const entries: LogEntry[] = [];
|
|
976
|
+
const sink = (e: Readonly<LogEntry>): void => {
|
|
977
|
+
entries.push({ ...e });
|
|
978
|
+
};
|
|
979
|
+
addLogSink(sink);
|
|
980
|
+
const removed = removeLogSink(sink);
|
|
981
|
+
expect(removed).toBe(true);
|
|
982
|
+
log(LogLevel.Info, 'x');
|
|
983
|
+
expect(entries).toHaveLength(0);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it('returns false when the sink was not registered', () => {
|
|
987
|
+
const sink = (): void => {};
|
|
988
|
+
expect(removeLogSink(sink)).toBe(false);
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('serializeLogError', () => {
|
|
993
|
+
it('extracts name, message, and stack from an Error', () => {
|
|
994
|
+
const err = new Error('boom');
|
|
995
|
+
const result = serializeLogError(err);
|
|
996
|
+
expect(result.name).toBe('Error');
|
|
997
|
+
expect(result.message).toBe('boom');
|
|
998
|
+
expect(typeof result.stack).toBe('string');
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it('recursively serializes the cause chain', () => {
|
|
1002
|
+
const inner = new Error('inner');
|
|
1003
|
+
const outer = new Error('outer', { cause: inner });
|
|
1004
|
+
const result = serializeLogError(outer);
|
|
1005
|
+
expect((result.cause as Record<string, unknown>).message).toBe('inner');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('wraps a non-Error value in { value }', () => {
|
|
1009
|
+
expect(serializeLogError('oops')).toEqual({ value: 'oops' });
|
|
1010
|
+
expect(serializeLogError(42)).toEqual({ value: '42' });
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
describe('setLogChannelLevel', () => {
|
|
1015
|
+
it('suppresses entries below the channel level', () => {
|
|
1016
|
+
const { entries } = recordingSink();
|
|
1017
|
+
setLogChannelLevel('render', LogLevel.Error);
|
|
1018
|
+
log(LogLevel.Info, 'info on render channel', 'render');
|
|
1019
|
+
log(LogLevel.Error, 'error on render channel', 'render');
|
|
1020
|
+
expect(entries).toHaveLength(1);
|
|
1021
|
+
expect(entries[0].level).toBe(LogLevel.Error);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('does not affect other channels', () => {
|
|
1025
|
+
const { entries } = recordingSink();
|
|
1026
|
+
setLogChannelLevel('render', LogLevel.Error);
|
|
1027
|
+
log(LogLevel.Info, 'other', 'audio');
|
|
1028
|
+
expect(entries).toHaveLength(1);
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('setLogConsoleLevel', () => {
|
|
1033
|
+
it('raises the threshold so finer levels print a human line', () => {
|
|
1034
|
+
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
1035
|
+
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1036
|
+
setLogConsoleLevel(LogLevel.Verbose);
|
|
1037
|
+
setLogSink(createConsoleCaptureSink());
|
|
1038
|
+
log(LogLevel.Verbose, 'now shown');
|
|
1039
|
+
expect(consoleLog).toHaveBeenCalledTimes(1);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
describe('setLogLevel', () => {
|
|
1044
|
+
it('suppresses entries below the global level', () => {
|
|
1045
|
+
const { entries } = recordingSink();
|
|
1046
|
+
setLogLevel(LogLevel.Error);
|
|
1047
|
+
log(LogLevel.Info, 'suppressed');
|
|
1048
|
+
log(LogLevel.Error, 'emitted');
|
|
1049
|
+
expect(entries).toHaveLength(1);
|
|
1050
|
+
expect(entries[0].data).toBe('emitted');
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('LogLevel.None always suppresses (never-emit guard)', () => {
|
|
1054
|
+
const { entries } = recordingSink();
|
|
1055
|
+
setLogLevel(LogLevel.None);
|
|
1056
|
+
log(LogLevel.None, 'should not emit');
|
|
1057
|
+
expect(entries).toHaveLength(0);
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
describe('setLogRedactionPaths', () => {
|
|
1062
|
+
it('replaces top-level fields matching a path', () => {
|
|
1063
|
+
setLogRedactionPaths(['password']);
|
|
1064
|
+
const fmt = createJsonLogFormatter();
|
|
1065
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: { user: 'alice', password: 'secret' } };
|
|
1066
|
+
const result = JSON.parse(fmt(entry));
|
|
1067
|
+
expect(result.data.password).toBe('[REDACTED]');
|
|
1068
|
+
expect(result.data.user).toBe('alice');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('replaces nested fields with dot-notation paths', () => {
|
|
1072
|
+
setLogRedactionPaths(['auth.secret']);
|
|
1073
|
+
const fmt = createJsonLogFormatter();
|
|
1074
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: { auth: { secret: 'tok', name: 'jwt' } } };
|
|
1075
|
+
const result = JSON.parse(fmt(entry));
|
|
1076
|
+
expect(result.data.auth.secret).toBe('[REDACTED]');
|
|
1077
|
+
expect(result.data.auth.name).toBe('jwt');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it('does not mutate the original data object (alias-safe)', () => {
|
|
1081
|
+
setLogRedactionPaths(['key']);
|
|
1082
|
+
const original = { key: 'value', other: 'safe' };
|
|
1083
|
+
const entry: LogEntry = { level: LogLevel.Info, channel: null, data: original };
|
|
1084
|
+
const fmt = createJsonLogFormatter();
|
|
1085
|
+
fmt(entry);
|
|
1086
|
+
// The original object should not be mutated
|
|
1087
|
+
expect(original.key).toBe('value');
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
describe('setLogSink', () => {
|
|
1092
|
+
it('clears the sink list when passed null', () => {
|
|
1093
|
+
const { entries } = recordingSink();
|
|
1094
|
+
setLogSink(null);
|
|
1095
|
+
log(LogLevel.Info, 'x');
|
|
1096
|
+
expect(entries).toHaveLength(0);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it('replaces all existing sinks with the single given sink', () => {
|
|
1100
|
+
const a: LogEntry[] = [];
|
|
1101
|
+
const b: LogEntry[] = [];
|
|
1102
|
+
addLogSink((e) => a.push({ ...e }));
|
|
1103
|
+
setLogSink((e) => b.push({ ...e }));
|
|
1104
|
+
log(LogLevel.Info, 'x');
|
|
1105
|
+
expect(a).toHaveLength(0);
|
|
1106
|
+
expect(b).toHaveLength(1);
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
describe('setLogTransportBackend', () => {
|
|
1111
|
+
it('sets and retrieves the backend', () => {
|
|
1112
|
+
const backend = createWebLogTransportBackend();
|
|
1113
|
+
setLogTransportBackend(backend);
|
|
1114
|
+
expect(getLogTransportBackend()).toBe(backend);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('clears the backend when null is passed', () => {
|
|
1118
|
+
setLogTransportBackend(createWebLogTransportBackend());
|
|
1119
|
+
setLogTransportBackend(null);
|
|
1120
|
+
expect(getLogTransportBackend()).toBeNull();
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
describe('startLogTimer', () => {
|
|
1125
|
+
it('returns a LogTimer with the given label and channel', () => {
|
|
1126
|
+
const timer = startLogTimer('render', 'perf');
|
|
1127
|
+
expect(timer.label).toBe('render');
|
|
1128
|
+
expect(timer.channel).toBe('perf');
|
|
1129
|
+
expect(timer.startedAt).toBeGreaterThanOrEqual(0);
|
|
1130
|
+
});
|
|
1131
|
+
});
|