@logtape/logtape 1.2.2 → 1.2.3

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/src/sink.test.ts DELETED
@@ -1,2590 +0,0 @@
1
- import { suite } from "@alinea/suite";
2
- import { assert } from "@std/assert/assert";
3
- import { assertEquals } from "@std/assert/equals";
4
- import { assertInstanceOf } from "@std/assert/instance-of";
5
- import { assertThrows } from "@std/assert/throws";
6
- import { delay } from "@std/async/delay";
7
- import makeConsoleMock from "consolemock";
8
- import { debug, error, fatal, info, trace, warning } from "./fixtures.ts";
9
- import { defaultTextFormatter } from "./formatter.ts";
10
- import type { LogLevel } from "./level.ts";
11
- import type { LogRecord } from "./record.ts";
12
- import {
13
- type AsyncSink,
14
- fingersCrossed,
15
- fromAsyncSink,
16
- getConsoleSink,
17
- getStreamSink,
18
- type Sink,
19
- withFilter,
20
- } from "./sink.ts";
21
-
22
- const test = suite(import.meta);
23
-
24
- test("withFilter()", () => {
25
- const buffer: LogRecord[] = [];
26
- const sink = withFilter(buffer.push.bind(buffer), "warning");
27
- sink(trace);
28
- sink(debug);
29
- sink(info);
30
- sink(warning);
31
- sink(error);
32
- sink(fatal);
33
- assertEquals(buffer, [warning, error, fatal]);
34
- });
35
-
36
- interface ConsoleMock extends Console {
37
- history(): unknown[];
38
- }
39
-
40
- test("getStreamSink()", async () => {
41
- let buffer: string = "";
42
- const decoder = new TextDecoder();
43
- const sink = getStreamSink(
44
- new WritableStream({
45
- write(chunk: Uint8Array) {
46
- buffer += decoder.decode(chunk);
47
- return Promise.resolve();
48
- },
49
- }),
50
- );
51
- sink(trace);
52
- sink(debug);
53
- sink(info);
54
- sink(warning);
55
- sink(error);
56
- sink(fatal);
57
- await sink[Symbol.asyncDispose]();
58
- assertEquals(
59
- buffer,
60
- `\
61
- 2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
62
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
63
- 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
64
- 2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!
65
- 2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!
66
- 2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!
67
- `,
68
- );
69
- });
70
-
71
- test("getStreamSink() with nonBlocking - simple boolean", async () => {
72
- let buffer: string = "";
73
- const decoder = new TextDecoder();
74
- const sink = getStreamSink(
75
- new WritableStream({
76
- write(chunk: Uint8Array) {
77
- buffer += decoder.decode(chunk);
78
- return Promise.resolve();
79
- },
80
- }),
81
- { nonBlocking: true },
82
- );
83
-
84
- // Check that it returns AsyncDisposable
85
- assertInstanceOf(sink, Function);
86
- assert(Symbol.asyncDispose in sink);
87
-
88
- // Add records - they should not be written immediately
89
- sink(trace);
90
- sink(debug);
91
- assertEquals(buffer, ""); // Not written yet
92
-
93
- // Wait for flush interval (default 100ms)
94
- await delay(150);
95
- assertEquals(
96
- buffer,
97
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
98
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
99
- `,
100
- );
101
-
102
- await sink[Symbol.asyncDispose]();
103
- });
104
-
105
- test("getStreamSink() with nonBlocking - custom buffer config", async () => {
106
- let buffer: string = "";
107
- const decoder = new TextDecoder();
108
- const sink = getStreamSink(
109
- new WritableStream({
110
- write(chunk: Uint8Array) {
111
- buffer += decoder.decode(chunk);
112
- return Promise.resolve();
113
- },
114
- }),
115
- {
116
- nonBlocking: {
117
- bufferSize: 2,
118
- flushInterval: 50,
119
- },
120
- },
121
- );
122
-
123
- // Add records up to buffer size
124
- sink(trace);
125
- assertEquals(buffer, ""); // Not flushed yet
126
-
127
- sink(debug); // This should trigger immediate flush (buffer size = 2)
128
- await delay(10); // Small delay for async flush
129
- assertEquals(
130
- buffer,
131
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
132
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
133
- `,
134
- );
135
-
136
- // Add more records
137
- const prevLength = buffer.length;
138
- sink(info);
139
- assertEquals(buffer.length, prevLength); // Not flushed yet
140
-
141
- // Wait for flush interval
142
- await delay(60);
143
- assertEquals(
144
- buffer.substring(prevLength),
145
- `2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
146
- `,
147
- );
148
-
149
- await sink[Symbol.asyncDispose]();
150
- });
151
-
152
- test("getStreamSink() with nonBlocking - no operations after dispose", async () => {
153
- let buffer: string = "";
154
- const decoder = new TextDecoder();
155
- const sink = getStreamSink(
156
- new WritableStream({
157
- write(chunk: Uint8Array) {
158
- buffer += decoder.decode(chunk);
159
- return Promise.resolve();
160
- },
161
- }),
162
- { nonBlocking: true },
163
- );
164
-
165
- // Dispose immediately
166
- await sink[Symbol.asyncDispose]();
167
-
168
- // Try to add records after dispose
169
- sink(trace);
170
- sink(debug);
171
-
172
- // No records should be written
173
- assertEquals(buffer, "");
174
- });
175
-
176
- test("getStreamSink() with nonBlocking - error handling", async () => {
177
- const sink = getStreamSink(
178
- new WritableStream({
179
- write() {
180
- return Promise.reject(new Error("Write error"));
181
- },
182
- }),
183
- { nonBlocking: true },
184
- );
185
-
186
- // Should not throw when adding records
187
- sink(trace);
188
- sink(info);
189
- sink(error);
190
-
191
- // Wait for flush - errors should be silently ignored
192
- await delay(150);
193
-
194
- // Dispose - should not throw
195
- await sink[Symbol.asyncDispose]();
196
- });
197
-
198
- test("getStreamSink() with nonBlocking - flush on dispose", async () => {
199
- let buffer: string = "";
200
- const decoder = new TextDecoder();
201
- const sink = getStreamSink(
202
- new WritableStream({
203
- write(chunk: Uint8Array) {
204
- buffer += decoder.decode(chunk);
205
- return Promise.resolve();
206
- },
207
- }),
208
- {
209
- nonBlocking: {
210
- bufferSize: 100,
211
- flushInterval: 5000, // Very long interval
212
- },
213
- },
214
- );
215
-
216
- // Add records
217
- sink(trace);
218
- sink(debug);
219
- sink(info);
220
- assertEquals(buffer, ""); // Not flushed yet due to large buffer and long interval
221
-
222
- // Dispose should flush all remaining records
223
- await sink[Symbol.asyncDispose]();
224
- assertEquals(
225
- buffer,
226
- `2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!
227
- 2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!
228
- 2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!
229
- `,
230
- );
231
- });
232
-
233
- test("getStreamSink() with nonBlocking - buffer overflow protection", async () => {
234
- let buffer: string = "";
235
- const decoder = new TextDecoder();
236
- let recordsReceived = 0;
237
- const sink = getStreamSink(
238
- new WritableStream({
239
- write(chunk: Uint8Array) {
240
- const text = decoder.decode(chunk);
241
- buffer += text;
242
- // Count how many log records we actually receive
243
- recordsReceived += text.split("\n").filter((line) =>
244
- line.trim() !== ""
245
- ).length;
246
- return Promise.resolve();
247
- },
248
- }),
249
- {
250
- nonBlocking: {
251
- bufferSize: 3,
252
- flushInterval: 50, // Short interval to ensure flushes happen
253
- },
254
- },
255
- );
256
-
257
- // Add many more records than maxBufferSize (6) very rapidly
258
- // This should trigger multiple flushes and potentially overflow protection
259
- for (let i = 0; i < 20; i++) {
260
- sink(trace);
261
- }
262
-
263
- // Wait for all flushes to complete
264
- await delay(200);
265
-
266
- // Force final flush
267
- await sink[Symbol.asyncDispose]();
268
-
269
- // Due to overflow protection, we should receive fewer than 20 records
270
- // The exact number depends on timing, but some should be dropped
271
- assert(
272
- recordsReceived < 20,
273
- `Expected < 20 records due to potential overflow, got ${recordsReceived}`,
274
- );
275
- assert(recordsReceived > 0, "Expected some records to be logged");
276
- });
277
-
278
- test("getStreamSink() with nonBlocking - high volume non-blocking behavior", async () => {
279
- let buffer: string = "";
280
- const decoder = new TextDecoder();
281
- const sink = getStreamSink(
282
- new WritableStream({
283
- write(chunk: Uint8Array) {
284
- buffer += decoder.decode(chunk);
285
- return Promise.resolve();
286
- },
287
- }),
288
- {
289
- nonBlocking: {
290
- bufferSize: 3,
291
- flushInterval: 50,
292
- },
293
- },
294
- );
295
-
296
- // Simulate rapid logging - this should not block
297
- const startTime = performance.now();
298
- for (let i = 0; i < 100; i++) {
299
- sink(trace);
300
- }
301
- const endTime = performance.now();
302
-
303
- // Adding logs should be very fast (non-blocking)
304
- const duration = endTime - startTime;
305
- assert(
306
- duration < 100,
307
- `Adding 100 logs took ${duration}ms, should be much faster`,
308
- );
309
-
310
- // Wait for flushes to complete
311
- await delay(200);
312
-
313
- // Should have logged some records
314
- assert(buffer.length > 0, "Expected some records to be logged");
315
-
316
- await sink[Symbol.asyncDispose]();
317
- });
318
-
319
- test("getConsoleSink()", () => {
320
- // @ts-ignore: consolemock is not typed
321
- const mock: ConsoleMock = makeConsoleMock();
322
- const sink = getConsoleSink({ console: mock });
323
- sink(trace);
324
- sink(debug);
325
- sink(info);
326
- sink(warning);
327
- sink(error);
328
- sink(fatal);
329
- assertEquals(mock.history(), [
330
- {
331
- DEBUG: [
332
- "%c22:13:20.000 %cTRC%c %cmy-app·junk %cHello, %o & %o!",
333
- "color: gray;",
334
- "background-color: gray; color: white;",
335
- "background-color: default;",
336
- "color: gray;",
337
- "color: default;",
338
- 123,
339
- 456,
340
- ],
341
- },
342
- {
343
- DEBUG: [
344
- "%c22:13:20.000 %cDBG%c %cmy-app·junk %cHello, %o & %o!",
345
- "color: gray;",
346
- "background-color: gray; color: white;",
347
- "background-color: default;",
348
- "color: gray;",
349
- "color: default;",
350
- 123,
351
- 456,
352
- ],
353
- },
354
- {
355
- INFO: [
356
- "%c22:13:20.000 %cINF%c %cmy-app·junk %cHello, %o & %o!",
357
- "color: gray;",
358
- "background-color: white; color: black;",
359
- "background-color: default;",
360
- "color: gray;",
361
- "color: default;",
362
- 123,
363
- 456,
364
- ],
365
- },
366
- {
367
- WARN: [
368
- "%c22:13:20.000 %cWRN%c %cmy-app·junk %cHello, %o & %o!",
369
- "color: gray;",
370
- "background-color: orange; color: black;",
371
- "background-color: default;",
372
- "color: gray;",
373
- "color: default;",
374
- 123,
375
- 456,
376
- ],
377
- },
378
- {
379
- ERROR: [
380
- "%c22:13:20.000 %cERR%c %cmy-app·junk %cHello, %o & %o!",
381
- "color: gray;",
382
- "background-color: red; color: white;",
383
- "background-color: default;",
384
- "color: gray;",
385
- "color: default;",
386
- 123,
387
- 456,
388
- ],
389
- },
390
- {
391
- ERROR: [
392
- "%c22:13:20.000 %cFTL%c %cmy-app·junk %cHello, %o & %o!",
393
- "color: gray;",
394
- "background-color: maroon; color: white;",
395
- "background-color: default;",
396
- "color: gray;",
397
- "color: default;",
398
- 123,
399
- 456,
400
- ],
401
- },
402
- ]);
403
-
404
- assertThrows(
405
- () => sink({ ...info, level: "invalid" as LogLevel }),
406
- TypeError,
407
- "Invalid log level: invalid.",
408
- );
409
-
410
- // @ts-ignore: consolemock is not typed
411
- const mock2: ConsoleMock = makeConsoleMock();
412
- const sink2 = getConsoleSink({
413
- console: mock2,
414
- formatter: defaultTextFormatter,
415
- });
416
- sink2(trace);
417
- sink2(debug);
418
- sink2(info);
419
- sink2(warning);
420
- sink2(error);
421
- sink2(fatal);
422
- assertEquals(mock2.history(), [
423
- {
424
- DEBUG: [
425
- "2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!",
426
- ],
427
- },
428
- {
429
- DEBUG: [
430
- "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!",
431
- ],
432
- },
433
- {
434
- INFO: [
435
- "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!",
436
- ],
437
- },
438
- {
439
- WARN: [
440
- "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!",
441
- ],
442
- },
443
- {
444
- ERROR: [
445
- "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!",
446
- ],
447
- },
448
- {
449
- ERROR: [
450
- "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!",
451
- ],
452
- },
453
- ]);
454
-
455
- // @ts-ignore: consolemock is not typed
456
- const mock3: ConsoleMock = makeConsoleMock();
457
- const sink3 = getConsoleSink({
458
- console: mock3,
459
- levelMap: {
460
- trace: "log",
461
- debug: "log",
462
- info: "log",
463
- warning: "log",
464
- error: "log",
465
- fatal: "log",
466
- },
467
- formatter: defaultTextFormatter,
468
- });
469
- sink3(trace);
470
- sink3(debug);
471
- sink3(info);
472
- sink3(warning);
473
- sink3(error);
474
- sink3(fatal);
475
- assertEquals(mock3.history(), [
476
- {
477
- LOG: [
478
- "2023-11-14 22:13:20.000 +00:00 [TRC] my-app·junk: Hello, 123 & 456!",
479
- ],
480
- },
481
- {
482
- LOG: [
483
- "2023-11-14 22:13:20.000 +00:00 [DBG] my-app·junk: Hello, 123 & 456!",
484
- ],
485
- },
486
- {
487
- LOG: [
488
- "2023-11-14 22:13:20.000 +00:00 [INF] my-app·junk: Hello, 123 & 456!",
489
- ],
490
- },
491
- {
492
- LOG: [
493
- "2023-11-14 22:13:20.000 +00:00 [WRN] my-app·junk: Hello, 123 & 456!",
494
- ],
495
- },
496
- {
497
- LOG: [
498
- "2023-11-14 22:13:20.000 +00:00 [ERR] my-app·junk: Hello, 123 & 456!",
499
- ],
500
- },
501
- {
502
- LOG: [
503
- "2023-11-14 22:13:20.000 +00:00 [FTL] my-app·junk: Hello, 123 & 456!",
504
- ],
505
- },
506
- ]);
507
- });
508
-
509
- test("getConsoleSink() with nonBlocking - simple boolean", async () => {
510
- // @ts-ignore: consolemock is not typed
511
- const mock: ConsoleMock = makeConsoleMock();
512
- const sink = getConsoleSink({ console: mock, nonBlocking: true });
513
-
514
- // Check that it returns a Disposable
515
- assertInstanceOf(sink, Function);
516
- assert(Symbol.dispose in sink);
517
-
518
- // Add records - they should not be logged immediately
519
- sink(trace);
520
- sink(debug);
521
- assertEquals(mock.history().length, 0); // Not logged yet
522
-
523
- // Wait for flush interval (default 100ms)
524
- await delay(150);
525
- assertEquals(mock.history().length, 2); // Now they should be logged
526
-
527
- // Dispose the sink
528
- (sink as Sink & Disposable)[Symbol.dispose]();
529
- });
530
-
531
- test("getConsoleSink() with nonBlocking - custom buffer config", async () => {
532
- // @ts-ignore: consolemock is not typed
533
- const mock: ConsoleMock = makeConsoleMock();
534
- const sink = getConsoleSink({
535
- console: mock,
536
- nonBlocking: {
537
- bufferSize: 3,
538
- flushInterval: 50,
539
- },
540
- });
541
-
542
- // Add records up to buffer size
543
- sink(trace);
544
- sink(debug);
545
- assertEquals(mock.history().length, 0); // Not flushed yet
546
-
547
- sink(info); // This should trigger scheduled flush (buffer size = 3)
548
- await delay(10); // Wait for scheduled flush to execute
549
- assertEquals(mock.history().length, 3); // Flushed due to buffer size
550
-
551
- // Add more records
552
- sink(warning);
553
- assertEquals(mock.history().length, 3); // Not flushed yet
554
-
555
- // Wait for flush interval
556
- await delay(60);
557
- assertEquals(mock.history().length, 4); // Flushed due to interval
558
-
559
- // Dispose and check remaining records are flushed
560
- sink(error);
561
- sink(fatal);
562
- (sink as Sink & Disposable)[Symbol.dispose]();
563
- assertEquals(mock.history().length, 6); // All records flushed on dispose
564
- });
565
-
566
- test("getConsoleSink() with nonBlocking - no operations after dispose", () => {
567
- // @ts-ignore: consolemock is not typed
568
- const mock: ConsoleMock = makeConsoleMock();
569
- const sink = getConsoleSink({ console: mock, nonBlocking: true });
570
-
571
- // Dispose immediately
572
- (sink as Sink & Disposable)[Symbol.dispose]();
573
-
574
- // Try to add records after dispose
575
- sink(trace);
576
- sink(debug);
577
-
578
- // No records should be logged
579
- assertEquals(mock.history().length, 0);
580
- });
581
-
582
- test("getConsoleSink() with nonBlocking - error handling", async () => {
583
- const errorConsole = {
584
- ...console,
585
- debug: () => {
586
- throw new Error("Console error");
587
- },
588
- info: () => {
589
- throw new Error("Console error");
590
- },
591
- warn: () => {
592
- throw new Error("Console error");
593
- },
594
- error: () => {
595
- throw new Error("Console error");
596
- },
597
- };
598
-
599
- const sink = getConsoleSink({
600
- console: errorConsole,
601
- nonBlocking: true,
602
- });
603
-
604
- // Should not throw when adding records
605
- sink(trace);
606
- sink(info);
607
- sink(error);
608
-
609
- // Wait for flush - errors should be silently ignored
610
- await delay(150);
611
-
612
- // Dispose - should not throw
613
- (sink as Sink & Disposable)[Symbol.dispose]();
614
- });
615
-
616
- test("getConsoleSink() with nonBlocking - buffer overflow protection", async () => {
617
- // @ts-ignore: consolemock is not typed
618
- const mock: ConsoleMock = makeConsoleMock();
619
- const sink = getConsoleSink({
620
- console: mock,
621
- nonBlocking: {
622
- bufferSize: 5,
623
- flushInterval: 1000, // Long interval to prevent automatic flushing
624
- },
625
- });
626
-
627
- // Add more records than 2x buffer size (which should trigger overflow protection)
628
- for (let i = 0; i < 12; i++) {
629
- sink(trace);
630
- }
631
-
632
- // Should have dropped oldest records, keeping buffer size manageable
633
- // Wait a bit for any scheduled flushes
634
- await delay(10);
635
-
636
- // Force flush by disposing
637
- (sink as Sink & Disposable)[Symbol.dispose]();
638
-
639
- // Should have logged records, but not more than maxBufferSize (10)
640
- const historyLength = mock.history().length;
641
- assert(historyLength <= 10, `Expected <= 10 records, got ${historyLength}`);
642
- assert(historyLength > 0, "Expected some records to be logged");
643
- });
644
-
645
- test("getConsoleSink() with nonBlocking - high volume non-blocking behavior", async () => {
646
- // @ts-ignore: consolemock is not typed
647
- const mock: ConsoleMock = makeConsoleMock();
648
- const sink = getConsoleSink({
649
- console: mock,
650
- nonBlocking: {
651
- bufferSize: 3,
652
- flushInterval: 50,
653
- },
654
- });
655
-
656
- // Simulate rapid logging - this should not block
657
- const startTime = performance.now();
658
- for (let i = 0; i < 100; i++) {
659
- sink(trace);
660
- }
661
- const endTime = performance.now();
662
-
663
- // Adding logs should be very fast (non-blocking)
664
- const duration = endTime - startTime;
665
- assert(
666
- duration < 100,
667
- `Adding 100 logs took ${duration}ms, should be much faster`,
668
- );
669
-
670
- // Wait for flushes to complete
671
- await delay(200);
672
-
673
- // Should have logged some records
674
- assert(mock.history().length > 0, "Expected some records to be logged");
675
-
676
- (sink as Sink & Disposable)[Symbol.dispose]();
677
- });
678
-
679
- test("fromAsyncSink() - basic functionality", async () => {
680
- const buffer: LogRecord[] = [];
681
- const asyncSink: AsyncSink = async (record) => {
682
- await delay(10);
683
- buffer.push(record);
684
- };
685
-
686
- const sink = fromAsyncSink(asyncSink);
687
-
688
- sink(trace);
689
- sink(debug);
690
- sink(info);
691
-
692
- // Records should not be in buffer immediately
693
- assertEquals(buffer.length, 0);
694
-
695
- // Wait for async operations to complete
696
- await sink[Symbol.asyncDispose]();
697
-
698
- // All records should be in buffer in order
699
- assertEquals(buffer.length, 3);
700
- assertEquals(buffer, [trace, debug, info]);
701
- });
702
-
703
- test("fromAsyncSink() - promise chaining preserves order", async () => {
704
- const buffer: LogRecord[] = [];
705
- const delays = [50, 10, 30]; // Different delays for each call
706
- let callIndex = 0;
707
-
708
- const asyncSink: AsyncSink = async (record) => {
709
- const delayTime = delays[callIndex % delays.length];
710
- callIndex++;
711
- await delay(delayTime);
712
- buffer.push(record);
713
- };
714
-
715
- const sink = fromAsyncSink(asyncSink);
716
-
717
- sink(trace);
718
- sink(debug);
719
- sink(info);
720
-
721
- await sink[Symbol.asyncDispose]();
722
-
723
- // Despite different delays, order should be preserved
724
- assertEquals(buffer.length, 3);
725
- assertEquals(buffer, [trace, debug, info]);
726
- });
727
-
728
- test("fromAsyncSink() - error handling", async () => {
729
- const buffer: LogRecord[] = [];
730
- let errorCount = 0;
731
-
732
- const asyncSink: AsyncSink = async (record) => {
733
- if (record.level === "error") {
734
- errorCount++;
735
- throw new Error("Async sink error");
736
- }
737
- await Promise.resolve(); // Ensure it's async
738
- buffer.push(record);
739
- };
740
-
741
- const sink = fromAsyncSink(asyncSink);
742
-
743
- sink(trace);
744
- sink(error); // This will throw in async sink
745
- sink(info);
746
-
747
- await sink[Symbol.asyncDispose]();
748
-
749
- // Error should be caught and not break the chain
750
- assertEquals(errorCount, 1);
751
- assertEquals(buffer.length, 2);
752
- assertEquals(buffer, [trace, info]);
753
- });
754
-
755
- test("fromAsyncSink() - multiple dispose calls", async () => {
756
- const buffer: LogRecord[] = [];
757
- const asyncSink: AsyncSink = async (record) => {
758
- await delay(10);
759
- buffer.push(record);
760
- };
761
-
762
- const sink = fromAsyncSink(asyncSink);
763
-
764
- sink(trace);
765
- sink(debug);
766
-
767
- // First dispose
768
- await sink[Symbol.asyncDispose]();
769
- assertEquals(buffer.length, 2);
770
-
771
- // Second dispose should be safe
772
- await sink[Symbol.asyncDispose]();
773
- assertEquals(buffer.length, 2);
774
-
775
- // Third dispose should be safe
776
- await sink[Symbol.asyncDispose]();
777
- assertEquals(buffer.length, 2);
778
- });
779
-
780
- test("fromAsyncSink() - concurrent calls", async () => {
781
- const buffer: LogRecord[] = [];
782
- let concurrentCalls = 0;
783
- let maxConcurrentCalls = 0;
784
-
785
- const asyncSink: AsyncSink = async (record) => {
786
- concurrentCalls++;
787
- maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
788
- await delay(20);
789
- buffer.push(record);
790
- concurrentCalls--;
791
- };
792
-
793
- const sink = fromAsyncSink(asyncSink);
794
-
795
- // Fire multiple calls rapidly
796
- for (let i = 0; i < 5; i++) {
797
- sink(trace);
798
- }
799
-
800
- await sink[Symbol.asyncDispose]();
801
-
802
- // Due to promise chaining, max concurrent calls should be 1
803
- assertEquals(maxConcurrentCalls, 1);
804
- assertEquals(buffer.length, 5);
805
- });
806
-
807
- test("fromAsyncSink() - works with synchronous exceptions", async () => {
808
- const buffer: LogRecord[] = [];
809
- let errorCount = 0;
810
-
811
- const asyncSink: AsyncSink = async (record) => {
812
- if (record.level === "fatal") {
813
- errorCount++;
814
- // Synchronous throw before any await
815
- throw new Error("Sync error in async sink");
816
- }
817
- await delay(10);
818
- buffer.push(record);
819
- };
820
-
821
- const sink = fromAsyncSink(asyncSink);
822
-
823
- sink(trace);
824
- sink(fatal); // This will throw synchronously in async sink
825
- sink(info);
826
-
827
- await sink[Symbol.asyncDispose]();
828
-
829
- // Error should still be caught
830
- assertEquals(errorCount, 1);
831
- assertEquals(buffer.length, 2);
832
- assertEquals(buffer, [trace, info]);
833
- });
834
-
835
- test("fromAsyncSink() - very long async operations", async () => {
836
- const buffer: LogRecord[] = [];
837
- const asyncSink: AsyncSink = async (record) => {
838
- await delay(100); // Longer delay
839
- buffer.push(record);
840
- };
841
-
842
- const sink = fromAsyncSink(asyncSink);
843
-
844
- sink(trace);
845
- sink(debug);
846
-
847
- // Don't wait, just dispose immediately
848
- const disposePromise = sink[Symbol.asyncDispose]();
849
-
850
- // Buffer should still be empty
851
- assertEquals(buffer.length, 0);
852
-
853
- // Wait for dispose to complete
854
- await disposePromise;
855
-
856
- // Now all records should be processed
857
- assertEquals(buffer.length, 2);
858
- assertEquals(buffer, [trace, debug]);
859
- });
860
-
861
- test("fromAsyncSink() - empty async sink", async () => {
862
- const asyncSink: AsyncSink = async () => {
863
- // Do nothing
864
- };
865
-
866
- const sink = fromAsyncSink(asyncSink);
867
-
868
- // Should not throw
869
- sink(trace);
870
- sink(debug);
871
-
872
- await sink[Symbol.asyncDispose]();
873
-
874
- // Test passes if no errors thrown
875
- assert(true);
876
- });
877
-
878
- test("fingersCrossed() - basic functionality", () => {
879
- const buffer: LogRecord[] = [];
880
- const sink = fingersCrossed(buffer.push.bind(buffer));
881
-
882
- // Add debug and info logs - should be buffered
883
- sink(trace);
884
- sink(debug);
885
- sink(info);
886
- assertEquals(buffer.length, 0); // Not flushed yet
887
-
888
- // Add warning - still buffered (default trigger is error)
889
- sink(warning);
890
- assertEquals(buffer.length, 0);
891
-
892
- // Add error - should trigger flush
893
- sink(error);
894
- assertEquals(buffer, [trace, debug, info, warning, error]);
895
-
896
- // After trigger, logs pass through directly
897
- sink(fatal);
898
- assertEquals(buffer, [trace, debug, info, warning, error, fatal]);
899
- });
900
-
901
- test("fingersCrossed() - custom trigger level", () => {
902
- const buffer: LogRecord[] = [];
903
- const sink = fingersCrossed(buffer.push.bind(buffer), {
904
- triggerLevel: "warning",
905
- });
906
-
907
- // Add logs below warning
908
- sink(trace);
909
- sink(debug);
910
- sink(info);
911
- assertEquals(buffer.length, 0);
912
-
913
- // Warning should trigger flush
914
- sink(warning);
915
- assertEquals(buffer, [trace, debug, info, warning]);
916
-
917
- // Subsequent logs pass through
918
- sink(error);
919
- assertEquals(buffer, [trace, debug, info, warning, error]);
920
- });
921
-
922
- test("fingersCrossed() - buffer overflow protection", () => {
923
- const buffer: LogRecord[] = [];
924
- const sink = fingersCrossed(buffer.push.bind(buffer), {
925
- maxBufferSize: 3,
926
- });
927
-
928
- // Add more logs than buffer size
929
- sink(trace);
930
- sink(debug);
931
- sink(info);
932
- sink(warning); // Should drop trace
933
- assertEquals(buffer.length, 0); // Still buffered
934
-
935
- // Trigger flush
936
- sink(error);
937
- // Should only have last 3 records + error
938
- assertEquals(buffer, [debug, info, warning, error]);
939
- });
940
-
941
- test("fingersCrossed() - multiple trigger events", () => {
942
- const buffer: LogRecord[] = [];
943
- const sink = fingersCrossed(buffer.push.bind(buffer));
944
-
945
- // First batch
946
- sink(debug);
947
- sink(info);
948
- sink(error); // Trigger
949
- assertEquals(buffer, [debug, info, error]);
950
-
951
- // After trigger, everything passes through
952
- sink(debug);
953
- assertEquals(buffer, [debug, info, error, debug]);
954
-
955
- sink(error); // Another error
956
- assertEquals(buffer, [debug, info, error, debug, error]);
957
- });
958
-
959
- test("fingersCrossed() - trigger includes fatal", () => {
960
- const buffer: LogRecord[] = [];
961
- const sink = fingersCrossed(buffer.push.bind(buffer), {
962
- triggerLevel: "error",
963
- });
964
-
965
- sink(debug);
966
- sink(info);
967
- assertEquals(buffer.length, 0);
968
-
969
- // Fatal should also trigger (since it's >= error)
970
- sink(fatal);
971
- assertEquals(buffer, [debug, info, fatal]);
972
- });
973
-
974
- test("fingersCrossed() - category isolation descendant mode", () => {
975
- const buffer: LogRecord[] = [];
976
- const sink = fingersCrossed(buffer.push.bind(buffer), {
977
- isolateByCategory: "descendant",
978
- });
979
-
980
- // Create test records with different categories
981
- const appDebug: LogRecord = {
982
- ...debug,
983
- category: ["app"],
984
- };
985
- const appModuleDebug: LogRecord = {
986
- ...debug,
987
- category: ["app", "module"],
988
- };
989
- const appModuleSubDebug: LogRecord = {
990
- ...debug,
991
- category: ["app", "module", "sub"],
992
- };
993
- const otherDebug: LogRecord = {
994
- ...debug,
995
- category: ["other"],
996
- };
997
- const appError: LogRecord = {
998
- ...error,
999
- category: ["app"],
1000
- };
1001
-
1002
- // Buffer logs in different categories
1003
- sink(appDebug);
1004
- sink(appModuleDebug);
1005
- sink(appModuleSubDebug);
1006
- sink(otherDebug);
1007
- assertEquals(buffer.length, 0);
1008
-
1009
- // Trigger in parent category
1010
- sink(appError);
1011
-
1012
- // Should flush parent and all descendants, but not other
1013
- assertEquals(buffer.length, 4); // app, app.module, app.module.sub, and trigger
1014
- assert(buffer.includes(appDebug));
1015
- assert(buffer.includes(appModuleDebug));
1016
- assert(buffer.includes(appModuleSubDebug));
1017
- assert(buffer.includes(appError));
1018
- assert(!buffer.includes(otherDebug));
1019
- });
1020
-
1021
- test("fingersCrossed() - category isolation ancestor mode", () => {
1022
- const buffer: LogRecord[] = [];
1023
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1024
- isolateByCategory: "ancestor",
1025
- });
1026
-
1027
- // Create test records
1028
- const appDebug: LogRecord = {
1029
- ...debug,
1030
- category: ["app"],
1031
- };
1032
- const appModuleDebug: LogRecord = {
1033
- ...debug,
1034
- category: ["app", "module"],
1035
- };
1036
- const appModuleSubDebug: LogRecord = {
1037
- ...debug,
1038
- category: ["app", "module", "sub"],
1039
- };
1040
- const appModuleSubError: LogRecord = {
1041
- ...error,
1042
- category: ["app", "module", "sub"],
1043
- };
1044
-
1045
- // Buffer logs
1046
- sink(appDebug);
1047
- sink(appModuleDebug);
1048
- sink(appModuleSubDebug);
1049
- assertEquals(buffer.length, 0);
1050
-
1051
- // Trigger in child category
1052
- sink(appModuleSubError);
1053
-
1054
- // Should flush child and all ancestors
1055
- assertEquals(buffer.length, 4);
1056
- assert(buffer.includes(appDebug));
1057
- assert(buffer.includes(appModuleDebug));
1058
- assert(buffer.includes(appModuleSubDebug));
1059
- assert(buffer.includes(appModuleSubError));
1060
- });
1061
-
1062
- test("fingersCrossed() - category isolation both mode", () => {
1063
- const buffer: LogRecord[] = [];
1064
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1065
- isolateByCategory: "both",
1066
- });
1067
-
1068
- // Create test records
1069
- const rootDebug: LogRecord = {
1070
- ...debug,
1071
- category: ["app"],
1072
- };
1073
- const parentDebug: LogRecord = {
1074
- ...debug,
1075
- category: ["app", "parent"],
1076
- };
1077
- const siblingDebug: LogRecord = {
1078
- ...debug,
1079
- category: ["app", "sibling"],
1080
- };
1081
- const childDebug: LogRecord = {
1082
- ...debug,
1083
- category: ["app", "parent", "child"],
1084
- };
1085
- const unrelatedDebug: LogRecord = {
1086
- ...debug,
1087
- category: ["other"],
1088
- };
1089
- const parentError: LogRecord = {
1090
- ...error,
1091
- category: ["app", "parent"],
1092
- };
1093
-
1094
- // Buffer logs
1095
- sink(rootDebug);
1096
- sink(parentDebug);
1097
- sink(siblingDebug);
1098
- sink(childDebug);
1099
- sink(unrelatedDebug);
1100
- assertEquals(buffer.length, 0);
1101
-
1102
- // Trigger in middle category
1103
- sink(parentError);
1104
-
1105
- // Should flush ancestors and descendants, but not siblings or unrelated
1106
- assertEquals(buffer.length, 4);
1107
- assert(buffer.includes(rootDebug)); // Ancestor
1108
- assert(buffer.includes(parentDebug)); // Same
1109
- assert(buffer.includes(childDebug)); // Descendant
1110
- assert(buffer.includes(parentError)); // Trigger
1111
- assert(!buffer.includes(siblingDebug)); // Sibling
1112
- assert(!buffer.includes(unrelatedDebug)); // Unrelated
1113
- });
1114
-
1115
- test("fingersCrossed() - custom category matcher", () => {
1116
- const buffer: LogRecord[] = [];
1117
-
1118
- // Custom matcher: only flush if categories share first element
1119
- const customMatcher = (
1120
- trigger: readonly string[],
1121
- buffered: readonly string[],
1122
- ): boolean => {
1123
- return trigger[0] === buffered[0];
1124
- };
1125
-
1126
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1127
- isolateByCategory: customMatcher,
1128
- });
1129
-
1130
- // Create test records
1131
- const app1Debug: LogRecord = {
1132
- ...debug,
1133
- category: ["app", "module1"],
1134
- };
1135
- const app2Debug: LogRecord = {
1136
- ...debug,
1137
- category: ["app", "module2"],
1138
- };
1139
- const otherDebug: LogRecord = {
1140
- ...debug,
1141
- category: ["other", "module"],
1142
- };
1143
- const appError: LogRecord = {
1144
- ...error,
1145
- category: ["app", "module3"],
1146
- };
1147
-
1148
- // Buffer logs
1149
- sink(app1Debug);
1150
- sink(app2Debug);
1151
- sink(otherDebug);
1152
- assertEquals(buffer.length, 0);
1153
-
1154
- // Trigger
1155
- sink(appError);
1156
-
1157
- // Should flush all with same first category element
1158
- assertEquals(buffer.length, 3);
1159
- assert(buffer.includes(app1Debug));
1160
- assert(buffer.includes(app2Debug));
1161
- assert(buffer.includes(appError));
1162
- assert(!buffer.includes(otherDebug));
1163
- });
1164
-
1165
- test("fingersCrossed() - isolated buffers maintain separate states", () => {
1166
- const buffer: LogRecord[] = [];
1167
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1168
- isolateByCategory: "descendant",
1169
- });
1170
-
1171
- // Create test records
1172
- const app1Debug: LogRecord = {
1173
- ...debug,
1174
- category: ["app1"],
1175
- };
1176
- const app1Error: LogRecord = {
1177
- ...error,
1178
- category: ["app1"],
1179
- };
1180
- const app2Debug: LogRecord = {
1181
- ...debug,
1182
- category: ["app2"],
1183
- };
1184
- const app2Info: LogRecord = {
1185
- ...info,
1186
- category: ["app2"],
1187
- };
1188
-
1189
- // Buffer in app1
1190
- sink(app1Debug);
1191
-
1192
- // Trigger app1
1193
- sink(app1Error);
1194
- assertEquals(buffer, [app1Debug, app1Error]);
1195
-
1196
- // Buffer in app2 (should still be buffering)
1197
- sink(app2Debug);
1198
- assertEquals(buffer, [app1Debug, app1Error]); // app2 still buffered
1199
-
1200
- // Add more to triggered app1 (should pass through)
1201
- sink(app1Debug);
1202
- assertEquals(buffer, [app1Debug, app1Error, app1Debug]);
1203
-
1204
- // app2 still buffering
1205
- sink(app2Info);
1206
- assertEquals(buffer, [app1Debug, app1Error, app1Debug]); // app2 still buffered
1207
- });
1208
-
1209
- test("fingersCrossed() - chronological order in category isolation", () => {
1210
- const buffer: LogRecord[] = [];
1211
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1212
- isolateByCategory: "both",
1213
- });
1214
-
1215
- // Create test records with different timestamps
1216
- const app1: LogRecord = {
1217
- ...debug,
1218
- category: ["app"],
1219
- timestamp: 1000,
1220
- };
1221
- const app2: LogRecord = {
1222
- ...debug,
1223
- category: ["app", "sub"],
1224
- timestamp: 2000,
1225
- };
1226
- const app3: LogRecord = {
1227
- ...info,
1228
- category: ["app"],
1229
- timestamp: 3000,
1230
- };
1231
- const app4: LogRecord = {
1232
- ...debug,
1233
- category: ["app", "sub"],
1234
- timestamp: 4000,
1235
- };
1236
- const appError: LogRecord = {
1237
- ...error,
1238
- category: ["app"],
1239
- timestamp: 5000,
1240
- };
1241
-
1242
- // Add out of order
1243
- sink(app3);
1244
- sink(app1);
1245
- sink(app4);
1246
- sink(app2);
1247
-
1248
- // Trigger
1249
- sink(appError);
1250
-
1251
- // Should be sorted by timestamp
1252
- assertEquals(buffer, [app1, app2, app3, app4, appError]);
1253
- });
1254
-
1255
- test("fingersCrossed() - empty buffer trigger", () => {
1256
- const buffer: LogRecord[] = [];
1257
- const sink = fingersCrossed(buffer.push.bind(buffer));
1258
-
1259
- // Trigger immediately without any buffered logs
1260
- sink(error);
1261
- assertEquals(buffer, [error]);
1262
-
1263
- // Continue to pass through
1264
- sink(debug);
1265
- assertEquals(buffer, [error, debug]);
1266
- });
1267
-
1268
- test("fingersCrossed() - buffer size per category in isolation mode", () => {
1269
- const buffer: LogRecord[] = [];
1270
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1271
- maxBufferSize: 2,
1272
- isolateByCategory: "descendant",
1273
- });
1274
-
1275
- // Create records for different categories
1276
- const app1Trace: LogRecord = { ...trace, category: ["app1"] };
1277
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1278
- const app1Info: LogRecord = { ...info, category: ["app1"] };
1279
- const app2Trace: LogRecord = { ...trace, category: ["app2"] };
1280
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1281
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1282
-
1283
- // Fill app1 buffer beyond max
1284
- sink(app1Trace);
1285
- sink(app1Debug);
1286
- sink(app1Info); // Should drop app1Trace
1287
-
1288
- // Fill app2 buffer
1289
- sink(app2Trace);
1290
- sink(app2Debug);
1291
-
1292
- // Trigger app1
1293
- sink(app1Error);
1294
-
1295
- // Should only have last 2 from app1 + error
1296
- assertEquals(buffer.length, 3);
1297
- assert(!buffer.some((r) => r === app1Trace)); // Dropped
1298
- assert(buffer.includes(app1Debug));
1299
- assert(buffer.includes(app1Info));
1300
- assert(buffer.includes(app1Error));
1301
- // app2 records should not be flushed
1302
- assert(!buffer.includes(app2Trace));
1303
- assert(!buffer.includes(app2Debug));
1304
- });
1305
-
1306
- test("fingersCrossed() - edge case: trigger level not in severity order", () => {
1307
- const buffer: LogRecord[] = [];
1308
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1309
- triggerLevel: "trace", // Lowest level triggers immediately
1310
- });
1311
-
1312
- // Everything should pass through immediately
1313
- sink(trace);
1314
- assertEquals(buffer, [trace]);
1315
-
1316
- sink(debug);
1317
- assertEquals(buffer, [trace, debug]);
1318
- });
1319
-
1320
- test("fingersCrossed() - edge case: invalid trigger level", () => {
1321
- const buffer: LogRecord[] = [];
1322
-
1323
- // Should throw TypeError during sink creation
1324
- assertThrows(
1325
- () => {
1326
- fingersCrossed(buffer.push.bind(buffer), {
1327
- triggerLevel: "invalid" as LogLevel,
1328
- });
1329
- },
1330
- TypeError,
1331
- "Invalid triggerLevel",
1332
- );
1333
- });
1334
-
1335
- test("fingersCrossed() - edge case: very large buffer size", () => {
1336
- const buffer: LogRecord[] = [];
1337
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1338
- maxBufferSize: Number.MAX_SAFE_INTEGER,
1339
- });
1340
-
1341
- // Add many records
1342
- for (let i = 0; i < 1000; i++) {
1343
- sink(debug);
1344
- }
1345
- assertEquals(buffer.length, 0); // Still buffered
1346
-
1347
- sink(error);
1348
- assertEquals(buffer.length, 1001); // All 1000 + error
1349
- });
1350
-
1351
- test("fingersCrossed() - edge case: zero buffer size", () => {
1352
- const buffer: LogRecord[] = [];
1353
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1354
- maxBufferSize: 0,
1355
- });
1356
-
1357
- // Nothing should be buffered
1358
- sink(debug);
1359
- sink(info);
1360
- assertEquals(buffer.length, 0);
1361
-
1362
- // Trigger should still work
1363
- sink(error);
1364
- assertEquals(buffer, [error]); // Only the trigger
1365
- });
1366
-
1367
- test("fingersCrossed() - edge case: negative buffer size", () => {
1368
- const buffer: LogRecord[] = [];
1369
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1370
- maxBufferSize: -1,
1371
- });
1372
-
1373
- // Should behave like zero
1374
- sink(debug);
1375
- sink(info);
1376
- assertEquals(buffer.length, 0);
1377
-
1378
- sink(error);
1379
- assertEquals(buffer, [error]);
1380
- });
1381
-
1382
- test("fingersCrossed() - edge case: same record logged multiple times", () => {
1383
- const buffer: LogRecord[] = [];
1384
- const sink = fingersCrossed(buffer.push.bind(buffer));
1385
-
1386
- // Log same record multiple times
1387
- sink(debug);
1388
- sink(debug);
1389
- sink(debug);
1390
- assertEquals(buffer.length, 0);
1391
-
1392
- sink(error);
1393
- // All instances should be preserved
1394
- assertEquals(buffer.length, 4);
1395
- assertEquals(buffer, [debug, debug, debug, error]);
1396
- });
1397
-
1398
- test("fingersCrossed() - edge case: empty category array", () => {
1399
- const buffer: LogRecord[] = [];
1400
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1401
- isolateByCategory: "both",
1402
- });
1403
-
1404
- const emptyCategory: LogRecord = {
1405
- ...debug,
1406
- category: [],
1407
- };
1408
-
1409
- const normalCategory: LogRecord = {
1410
- ...info,
1411
- category: ["app"],
1412
- };
1413
-
1414
- const emptyError: LogRecord = {
1415
- ...error,
1416
- category: [],
1417
- };
1418
-
1419
- sink(emptyCategory);
1420
- sink(normalCategory);
1421
- assertEquals(buffer.length, 0);
1422
-
1423
- // Trigger with empty category
1424
- sink(emptyError);
1425
-
1426
- // Only empty category should flush (no ancestors/descendants)
1427
- assertEquals(buffer.length, 2);
1428
- assert(buffer.includes(emptyCategory));
1429
- assert(buffer.includes(emptyError));
1430
- assert(!buffer.includes(normalCategory));
1431
- });
1432
-
1433
- test("fingersCrossed() - edge case: category with special characters", () => {
1434
- const buffer: LogRecord[] = [];
1435
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1436
- isolateByCategory: "descendant",
1437
- });
1438
-
1439
- // Category with null character (our separator)
1440
- const specialCategory: LogRecord = {
1441
- ...debug,
1442
- category: ["app\0special", "sub"],
1443
- };
1444
-
1445
- const normalCategory: LogRecord = {
1446
- ...info,
1447
- category: ["app"],
1448
- };
1449
-
1450
- const specialError: LogRecord = {
1451
- ...error,
1452
- category: ["app\0special"],
1453
- };
1454
-
1455
- sink(specialCategory);
1456
- sink(normalCategory);
1457
- assertEquals(buffer.length, 0);
1458
-
1459
- // Should still work correctly despite special characters
1460
- sink(specialError);
1461
-
1462
- assertEquals(buffer.length, 2);
1463
- assert(buffer.includes(specialCategory));
1464
- assert(buffer.includes(specialError));
1465
- assert(!buffer.includes(normalCategory));
1466
- });
1467
-
1468
- test("fingersCrossed() - edge case: rapid alternating triggers", () => {
1469
- const buffer: LogRecord[] = [];
1470
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1471
- isolateByCategory: "both",
1472
- });
1473
-
1474
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1475
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1476
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1477
- const app2Error: LogRecord = { ...error, category: ["app2"] };
1478
-
1479
- // Rapidly alternate between categories and triggers
1480
- sink(app1Debug);
1481
- sink(app2Debug);
1482
- sink(app1Error); // Trigger app1
1483
- assertEquals(buffer.length, 2); // app1Debug + app1Error
1484
-
1485
- sink(app2Error); // Trigger app2
1486
- assertEquals(buffer.length, 4); // Previous + app2Debug + app2Error
1487
-
1488
- // After both triggered, everything passes through
1489
- sink(app1Debug);
1490
- sink(app2Debug);
1491
- assertEquals(buffer.length, 6);
1492
- });
1493
-
1494
- test("fingersCrossed() - edge case: custom matcher throws error", () => {
1495
- const buffer: LogRecord[] = [];
1496
-
1497
- const errorMatcher = (): boolean => {
1498
- throw new Error("Matcher error");
1499
- };
1500
-
1501
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1502
- isolateByCategory: errorMatcher,
1503
- });
1504
-
1505
- const app1Debug: LogRecord = { ...debug, category: ["app1"] };
1506
- const app2Debug: LogRecord = { ...debug, category: ["app2"] };
1507
- const app1Error: LogRecord = { ...error, category: ["app1"] };
1508
-
1509
- sink(app1Debug);
1510
- sink(app2Debug);
1511
-
1512
- // Should handle error gracefully and still trigger
1513
- try {
1514
- sink(app1Error);
1515
- } catch {
1516
- // Should not throw to caller
1517
- }
1518
-
1519
- // At minimum, trigger record should be sent
1520
- assert(buffer.includes(app1Error));
1521
- });
1522
-
1523
- test("fingersCrossed() - edge case: circular category references", () => {
1524
- const buffer: LogRecord[] = [];
1525
-
1526
- // Custom matcher that creates circular logic
1527
- const circularMatcher = (
1528
- _trigger: readonly string[],
1529
- _buffered: readonly string[],
1530
- ): boolean => {
1531
- // Always return true, creating a circular flush
1532
- return true;
1533
- };
1534
-
1535
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1536
- isolateByCategory: circularMatcher,
1537
- });
1538
-
1539
- const app1: LogRecord = { ...debug, category: ["app1"] };
1540
- const app2: LogRecord = { ...debug, category: ["app2"] };
1541
- const app3: LogRecord = { ...debug, category: ["app3"] };
1542
- const trigger: LogRecord = { ...error, category: ["trigger"] };
1543
-
1544
- sink(app1);
1545
- sink(app2);
1546
- sink(app3);
1547
- assertEquals(buffer.length, 0);
1548
-
1549
- // Should flush all despite circular logic
1550
- sink(trigger);
1551
- assertEquals(buffer.length, 4);
1552
-
1553
- // All buffers should be cleared after flush
1554
- const newDebug: LogRecord = { ...debug, category: ["new"] };
1555
- sink(newDebug);
1556
- assertEquals(buffer.length, 4); // New category should be buffered
1557
- });
1558
-
1559
- test("fingersCrossed() - edge case: timestamps in wrong order", () => {
1560
- const buffer: LogRecord[] = [];
1561
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1562
- isolateByCategory: "both",
1563
- });
1564
-
1565
- const future: LogRecord = {
1566
- ...debug,
1567
- category: ["app"],
1568
- timestamp: Date.now() + 10000, // Future
1569
- };
1570
-
1571
- const past: LogRecord = {
1572
- ...info,
1573
- category: ["app", "sub"],
1574
- timestamp: Date.now() - 10000, // Past
1575
- };
1576
-
1577
- const present: LogRecord = {
1578
- ...warning,
1579
- category: ["app"],
1580
- timestamp: Date.now(),
1581
- };
1582
-
1583
- const trigger: LogRecord = {
1584
- ...error,
1585
- category: ["app"],
1586
- timestamp: Date.now() + 5000,
1587
- };
1588
-
1589
- // Add in random order
1590
- sink(future);
1591
- sink(past);
1592
- sink(present);
1593
-
1594
- // Trigger
1595
- sink(trigger);
1596
-
1597
- // Should be sorted by timestamp
1598
- assertEquals(buffer[0], past);
1599
- assertEquals(buffer[1], present);
1600
- assertEquals(buffer[2], future);
1601
- assertEquals(buffer[3], trigger);
1602
- });
1603
-
1604
- test("fingersCrossed() - edge case: NaN and Infinity in timestamps", () => {
1605
- const buffer: LogRecord[] = [];
1606
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1607
- isolateByCategory: "both",
1608
- });
1609
-
1610
- const nanTime: LogRecord = {
1611
- ...debug,
1612
- category: ["app"],
1613
- timestamp: NaN,
1614
- };
1615
-
1616
- const infinityTime: LogRecord = {
1617
- ...info,
1618
- category: ["app"],
1619
- timestamp: Infinity,
1620
- };
1621
-
1622
- const negInfinityTime: LogRecord = {
1623
- ...warning,
1624
- category: ["app"],
1625
- timestamp: -Infinity,
1626
- };
1627
-
1628
- const normalTime: LogRecord = {
1629
- ...error,
1630
- category: ["app"],
1631
- timestamp: 1000,
1632
- };
1633
-
1634
- sink(nanTime);
1635
- sink(infinityTime);
1636
- sink(negInfinityTime);
1637
-
1638
- // Should handle special values without crashing
1639
- sink(normalTime);
1640
-
1641
- // Check all records are present (order might vary with NaN)
1642
- assertEquals(buffer.length, 4);
1643
- assert(buffer.includes(nanTime));
1644
- assert(buffer.includes(infinityTime));
1645
- assert(buffer.includes(negInfinityTime));
1646
- assert(buffer.includes(normalTime));
1647
- });
1648
-
1649
- test("fingersCrossed() - edge case: undefined properties in record", () => {
1650
- const buffer: LogRecord[] = [];
1651
- const sink = fingersCrossed(buffer.push.bind(buffer));
1652
-
1653
- const weirdRecord: LogRecord = {
1654
- ...debug,
1655
- properties: {
1656
- normal: "value",
1657
- undef: undefined,
1658
- nullish: null,
1659
- nan: NaN,
1660
- inf: Infinity,
1661
- },
1662
- };
1663
-
1664
- sink(weirdRecord);
1665
- sink(error);
1666
-
1667
- // Should preserve all properties as-is
1668
- assertEquals(buffer[0].properties, weirdRecord.properties);
1669
- });
1670
-
1671
- test("fingersCrossed() - edge case: very deep category hierarchy", () => {
1672
- const buffer: LogRecord[] = [];
1673
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1674
- isolateByCategory: "both",
1675
- });
1676
-
1677
- // Create very deep hierarchy
1678
- const deepCategory = Array.from({ length: 100 }, (_, i) => `level${i}`);
1679
- const parentCategory = deepCategory.slice(0, 50);
1680
-
1681
- const deepRecord: LogRecord = {
1682
- ...debug,
1683
- category: deepCategory,
1684
- };
1685
-
1686
- const parentRecord: LogRecord = {
1687
- ...info,
1688
- category: parentCategory,
1689
- };
1690
-
1691
- const deepError: LogRecord = {
1692
- ...error,
1693
- category: deepCategory,
1694
- };
1695
-
1696
- sink(deepRecord);
1697
- sink(parentRecord);
1698
- assertEquals(buffer.length, 0);
1699
-
1700
- // Should handle deep hierarchies
1701
- sink(deepError);
1702
-
1703
- // Both should flush (ancestor relationship)
1704
- assertEquals(buffer.length, 3);
1705
- assert(buffer.includes(deepRecord));
1706
- assert(buffer.includes(parentRecord));
1707
- assert(buffer.includes(deepError));
1708
- });
1709
-
1710
- test("fingersCrossed() - context isolation basic functionality", () => {
1711
- const buffer: LogRecord[] = [];
1712
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1713
- isolateByContext: { keys: ["requestId"] },
1714
- });
1715
-
1716
- // Create records with different request IDs
1717
- const req1Debug: LogRecord = {
1718
- ...debug,
1719
- properties: { requestId: "req-1", data: "debug1" },
1720
- };
1721
- const req1Info: LogRecord = {
1722
- ...info,
1723
- properties: { requestId: "req-1", data: "info1" },
1724
- };
1725
- const req1Error: LogRecord = {
1726
- ...error,
1727
- properties: { requestId: "req-1", data: "error1" },
1728
- };
1729
-
1730
- const req2Debug: LogRecord = {
1731
- ...debug,
1732
- properties: { requestId: "req-2", data: "debug2" },
1733
- };
1734
- const req2Info: LogRecord = {
1735
- ...info,
1736
- properties: { requestId: "req-2", data: "info2" },
1737
- };
1738
-
1739
- // Buffer logs for both requests
1740
- sink(req1Debug);
1741
- sink(req1Info);
1742
- sink(req2Debug);
1743
- sink(req2Info);
1744
- assertEquals(buffer.length, 0); // All buffered
1745
-
1746
- // Error in req-1 should only flush req-1 logs
1747
- sink(req1Error);
1748
- assertEquals(buffer.length, 3);
1749
- assertEquals(buffer[0], req1Debug);
1750
- assertEquals(buffer[1], req1Info);
1751
- assertEquals(buffer[2], req1Error);
1752
-
1753
- // req-2 logs should still be buffered
1754
- buffer.length = 0;
1755
- sink(req2Debug); // Add another req-2 log
1756
- assertEquals(buffer.length, 0); // Still buffered
1757
-
1758
- // Now trigger req-2
1759
- const req2Error: LogRecord = {
1760
- ...error,
1761
- properties: { requestId: "req-2", data: "error2" },
1762
- };
1763
- sink(req2Error);
1764
- assertEquals(buffer.length, 4); // 2x req2Debug + req2Info + req2Error
1765
- assertEquals(buffer[0], req2Debug);
1766
- assertEquals(buffer[1], req2Info);
1767
- assertEquals(buffer[2], req2Debug); // Second instance
1768
- assertEquals(buffer[3], req2Error);
1769
- });
1770
-
1771
- test("fingersCrossed() - context isolation with multiple keys", () => {
1772
- const buffer: LogRecord[] = [];
1773
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1774
- isolateByContext: { keys: ["requestId", "sessionId"] },
1775
- });
1776
-
1777
- // Create records with different combinations
1778
- const record1: LogRecord = {
1779
- ...debug,
1780
- properties: { requestId: "req-1", sessionId: "sess-1" },
1781
- };
1782
- const record2: LogRecord = {
1783
- ...debug,
1784
- properties: { requestId: "req-1", sessionId: "sess-2" },
1785
- };
1786
- const record3: LogRecord = {
1787
- ...debug,
1788
- properties: { requestId: "req-2", sessionId: "sess-1" },
1789
- };
1790
-
1791
- sink(record1);
1792
- sink(record2);
1793
- sink(record3);
1794
- assertEquals(buffer.length, 0); // All buffered
1795
-
1796
- // Error with req-1/sess-1 should only flush that combination
1797
- const trigger1: LogRecord = {
1798
- ...error,
1799
- properties: { requestId: "req-1", sessionId: "sess-1" },
1800
- };
1801
- sink(trigger1);
1802
- assertEquals(buffer.length, 2);
1803
- assertEquals(buffer[0], record1);
1804
- assertEquals(buffer[1], trigger1);
1805
-
1806
- // Other combinations still buffered
1807
- buffer.length = 0;
1808
- const trigger2: LogRecord = {
1809
- ...error,
1810
- properties: { requestId: "req-1", sessionId: "sess-2" },
1811
- };
1812
- sink(trigger2);
1813
- assertEquals(buffer.length, 2);
1814
- assertEquals(buffer[0], record2);
1815
- assertEquals(buffer[1], trigger2);
1816
- });
1817
-
1818
- test("fingersCrossed() - context isolation with missing keys", () => {
1819
- const buffer: LogRecord[] = [];
1820
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1821
- isolateByContext: { keys: ["requestId"] },
1822
- });
1823
-
1824
- // Records with and without requestId
1825
- const withId: LogRecord = {
1826
- ...debug,
1827
- properties: { requestId: "req-1", other: "data" },
1828
- };
1829
- const withoutId: LogRecord = {
1830
- ...debug,
1831
- properties: { other: "data" },
1832
- };
1833
- const withUndefinedId: LogRecord = {
1834
- ...debug,
1835
- properties: { requestId: undefined, other: "data" },
1836
- };
1837
-
1838
- sink(withId);
1839
- sink(withoutId);
1840
- sink(withUndefinedId);
1841
- assertEquals(buffer.length, 0); // All buffered
1842
-
1843
- // Error without requestId should flush records without or with undefined requestId
1844
- const triggerNoId: LogRecord = {
1845
- ...error,
1846
- properties: { other: "data" },
1847
- };
1848
- sink(triggerNoId);
1849
- assertEquals(buffer.length, 3); // withoutId + withUndefinedId + triggerNoId
1850
- assertEquals(buffer[0], withoutId);
1851
- assertEquals(buffer[1], withUndefinedId);
1852
- assertEquals(buffer[2], triggerNoId);
1853
-
1854
- // Records with requestId still buffered
1855
- buffer.length = 0;
1856
- const triggerWithId: LogRecord = {
1857
- ...error,
1858
- properties: { requestId: "req-1", other: "data" },
1859
- };
1860
- sink(triggerWithId);
1861
- assertEquals(buffer.length, 2);
1862
- assertEquals(buffer[0], withId);
1863
- assertEquals(buffer[1], triggerWithId);
1864
- });
1865
-
1866
- test("fingersCrossed() - combined category and context isolation", () => {
1867
- const buffer: LogRecord[] = [];
1868
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1869
- isolateByCategory: "descendant",
1870
- isolateByContext: { keys: ["requestId"] },
1871
- });
1872
-
1873
- // Create records with different categories and contexts
1874
- const appReq1: LogRecord = {
1875
- ...debug,
1876
- category: ["app"],
1877
- properties: { requestId: "req-1" },
1878
- };
1879
- const appModuleReq1: LogRecord = {
1880
- ...debug,
1881
- category: ["app", "module"],
1882
- properties: { requestId: "req-1" },
1883
- };
1884
- const appReq2: LogRecord = {
1885
- ...debug,
1886
- category: ["app"],
1887
- properties: { requestId: "req-2" },
1888
- };
1889
- const appModuleReq2: LogRecord = {
1890
- ...debug,
1891
- category: ["app", "module"],
1892
- properties: { requestId: "req-2" },
1893
- };
1894
- const otherReq1: LogRecord = {
1895
- ...debug,
1896
- category: ["other"],
1897
- properties: { requestId: "req-1" },
1898
- };
1899
-
1900
- sink(appReq1);
1901
- sink(appModuleReq1);
1902
- sink(appReq2);
1903
- sink(appModuleReq2);
1904
- sink(otherReq1);
1905
- assertEquals(buffer.length, 0); // All buffered
1906
-
1907
- // Error in ["app"] with req-1 should flush descendants with same requestId
1908
- const triggerAppReq1: LogRecord = {
1909
- ...error,
1910
- category: ["app"],
1911
- properties: { requestId: "req-1" },
1912
- };
1913
- sink(triggerAppReq1);
1914
- assertEquals(buffer.length, 3);
1915
- assertEquals(buffer[0], appReq1);
1916
- assertEquals(buffer[1], appModuleReq1);
1917
- assertEquals(buffer[2], triggerAppReq1);
1918
-
1919
- // Other combinations still buffered
1920
- buffer.length = 0;
1921
- const triggerAppReq2: LogRecord = {
1922
- ...error,
1923
- category: ["app"],
1924
- properties: { requestId: "req-2" },
1925
- };
1926
- sink(triggerAppReq2);
1927
- assertEquals(buffer.length, 3);
1928
- assertEquals(buffer[0], appReq2);
1929
- assertEquals(buffer[1], appModuleReq2);
1930
- assertEquals(buffer[2], triggerAppReq2);
1931
- });
1932
-
1933
- test("fingersCrossed() - context isolation buffer size limits", () => {
1934
- const buffer: LogRecord[] = [];
1935
- const sink = fingersCrossed(buffer.push.bind(buffer), {
1936
- maxBufferSize: 2,
1937
- isolateByContext: { keys: ["requestId"] },
1938
- });
1939
-
1940
- // Create records for different contexts
1941
- const req1Trace: LogRecord = {
1942
- ...trace,
1943
- properties: { requestId: "req-1" },
1944
- };
1945
- const req1Debug: LogRecord = {
1946
- ...debug,
1947
- properties: { requestId: "req-1" },
1948
- };
1949
- const req1Info: LogRecord = {
1950
- ...info,
1951
- properties: { requestId: "req-1" },
1952
- };
1953
- const req2Trace: LogRecord = {
1954
- ...trace,
1955
- properties: { requestId: "req-2" },
1956
- };
1957
- const req2Debug: LogRecord = {
1958
- ...debug,
1959
- properties: { requestId: "req-2" },
1960
- };
1961
-
1962
- // Fill req-1 buffer beyond limit
1963
- sink(req1Trace);
1964
- sink(req1Debug);
1965
- sink(req1Info); // Should drop req1Trace
1966
-
1967
- // Fill req-2 buffer
1968
- sink(req2Trace);
1969
- sink(req2Debug);
1970
-
1971
- // Trigger req-1
1972
- const req1Error: LogRecord = {
1973
- ...error,
1974
- properties: { requestId: "req-1" },
1975
- };
1976
- sink(req1Error);
1977
-
1978
- // Should only have the last 2 records plus error
1979
- assertEquals(buffer.length, 3);
1980
- assertEquals(buffer[0], req1Debug);
1981
- assertEquals(buffer[1], req1Info);
1982
- assertEquals(buffer[2], req1Error);
1983
-
1984
- // Trigger req-2
1985
- buffer.length = 0;
1986
- const req2Error: LogRecord = {
1987
- ...error,
1988
- properties: { requestId: "req-2" },
1989
- };
1990
- sink(req2Error);
1991
-
1992
- // req-2 buffer should still have both records
1993
- assertEquals(buffer.length, 3);
1994
- assertEquals(buffer[0], req2Trace);
1995
- assertEquals(buffer[1], req2Debug);
1996
- assertEquals(buffer[2], req2Error);
1997
- });
1998
-
1999
- test("fingersCrossed() - context isolation with special values", () => {
2000
- const buffer: LogRecord[] = [];
2001
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2002
- isolateByContext: { keys: ["value"] },
2003
- });
2004
-
2005
- // Records with special values
2006
- const nullValue: LogRecord = {
2007
- ...debug,
2008
- properties: { value: null },
2009
- };
2010
- const undefinedValue: LogRecord = {
2011
- ...debug,
2012
- properties: { value: undefined },
2013
- };
2014
- const zeroValue: LogRecord = {
2015
- ...debug,
2016
- properties: { value: 0 },
2017
- };
2018
- const emptyString: LogRecord = {
2019
- ...debug,
2020
- properties: { value: "" },
2021
- };
2022
- const falseValue: LogRecord = {
2023
- ...debug,
2024
- properties: { value: false },
2025
- };
2026
-
2027
- sink(nullValue);
2028
- sink(undefinedValue);
2029
- sink(zeroValue);
2030
- sink(emptyString);
2031
- sink(falseValue);
2032
- assertEquals(buffer.length, 0); // All buffered
2033
-
2034
- // Trigger with null value
2035
- const triggerNull: LogRecord = {
2036
- ...error,
2037
- properties: { value: null },
2038
- };
2039
- sink(triggerNull);
2040
- assertEquals(buffer.length, 2);
2041
- assertEquals(buffer[0], nullValue);
2042
- assertEquals(buffer[1], triggerNull);
2043
-
2044
- // Trigger with zero value
2045
- buffer.length = 0;
2046
- const triggerZero: LogRecord = {
2047
- ...error,
2048
- properties: { value: 0 },
2049
- };
2050
- sink(triggerZero);
2051
- assertEquals(buffer.length, 2);
2052
- assertEquals(buffer[0], zeroValue);
2053
- assertEquals(buffer[1], triggerZero);
2054
-
2055
- // Trigger with false value
2056
- buffer.length = 0;
2057
- const triggerFalse: LogRecord = {
2058
- ...error,
2059
- properties: { value: false },
2060
- };
2061
- sink(triggerFalse);
2062
- assertEquals(buffer.length, 2);
2063
- assertEquals(buffer[0], falseValue);
2064
- assertEquals(buffer[1], triggerFalse);
2065
- });
2066
-
2067
- test("fingersCrossed() - context isolation only (no category isolation)", () => {
2068
- const buffer: LogRecord[] = [];
2069
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2070
- isolateByContext: { keys: ["requestId"] },
2071
- });
2072
-
2073
- // Different categories, same context
2074
- const cat1Req1: LogRecord = {
2075
- ...debug,
2076
- category: ["cat1"],
2077
- properties: { requestId: "req-1" },
2078
- };
2079
- const cat2Req1: LogRecord = {
2080
- ...debug,
2081
- category: ["cat2"],
2082
- properties: { requestId: "req-1" },
2083
- };
2084
- const cat1Req2: LogRecord = {
2085
- ...debug,
2086
- category: ["cat1"],
2087
- properties: { requestId: "req-2" },
2088
- };
2089
-
2090
- sink(cat1Req1);
2091
- sink(cat2Req1);
2092
- sink(cat1Req2);
2093
- assertEquals(buffer.length, 0); // All buffered
2094
-
2095
- // Error in any category with req-1 should flush all req-1 logs
2096
- const triggerReq1: LogRecord = {
2097
- ...error,
2098
- category: ["cat3"],
2099
- properties: { requestId: "req-1" },
2100
- };
2101
- sink(triggerReq1);
2102
- assertEquals(buffer.length, 3);
2103
- assertEquals(buffer[0], cat1Req1);
2104
- assertEquals(buffer[1], cat2Req1);
2105
- assertEquals(buffer[2], triggerReq1);
2106
-
2107
- // req-2 still buffered
2108
- buffer.length = 0;
2109
- const triggerReq2: LogRecord = {
2110
- ...error,
2111
- category: ["cat1"],
2112
- properties: { requestId: "req-2" },
2113
- };
2114
- sink(triggerReq2);
2115
- assertEquals(buffer.length, 2);
2116
- assertEquals(buffer[0], cat1Req2);
2117
- assertEquals(buffer[1], triggerReq2);
2118
- });
2119
-
2120
- test("fingersCrossed() - context isolation with nested objects", () => {
2121
- const buffer: LogRecord[] = [];
2122
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2123
- isolateByContext: { keys: ["user"] },
2124
- });
2125
-
2126
- // Records with nested object values
2127
- const user1: LogRecord = {
2128
- ...debug,
2129
- properties: { user: { id: 1, name: "Alice" } },
2130
- };
2131
- const user1Same: LogRecord = {
2132
- ...debug,
2133
- properties: { user: { id: 1, name: "Alice" } },
2134
- };
2135
- const user2: LogRecord = {
2136
- ...debug,
2137
- properties: { user: { id: 2, name: "Bob" } },
2138
- };
2139
-
2140
- sink(user1);
2141
- sink(user1Same);
2142
- sink(user2);
2143
- assertEquals(buffer.length, 0); // All buffered
2144
-
2145
- // Trigger with same user object
2146
- const triggerUser1: LogRecord = {
2147
- ...error,
2148
- properties: { user: { id: 1, name: "Alice" } },
2149
- };
2150
- sink(triggerUser1);
2151
- assertEquals(buffer.length, 3);
2152
- assertEquals(buffer[0], user1);
2153
- assertEquals(buffer[1], user1Same);
2154
- assertEquals(buffer[2], triggerUser1);
2155
-
2156
- // user2 still buffered
2157
- buffer.length = 0;
2158
- const triggerUser2: LogRecord = {
2159
- ...error,
2160
- properties: { user: { id: 2, name: "Bob" } },
2161
- };
2162
- sink(triggerUser2);
2163
- assertEquals(buffer.length, 2);
2164
- assertEquals(buffer[0], user2);
2165
- assertEquals(buffer[1], triggerUser2);
2166
- });
2167
-
2168
- test("fingersCrossed() - context isolation after trigger", () => {
2169
- const buffer: LogRecord[] = [];
2170
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2171
- isolateByContext: { keys: ["requestId"] },
2172
- });
2173
-
2174
- // Trigger req-1 immediately
2175
- const req1Error: LogRecord = {
2176
- ...error,
2177
- properties: { requestId: "req-1" },
2178
- };
2179
- sink(req1Error);
2180
- assertEquals(buffer.length, 1);
2181
- assertEquals(buffer[0], req1Error);
2182
-
2183
- // After trigger, req-1 logs pass through
2184
- const req1Debug: LogRecord = {
2185
- ...debug,
2186
- properties: { requestId: "req-1" },
2187
- };
2188
- sink(req1Debug);
2189
- assertEquals(buffer.length, 2);
2190
- assertEquals(buffer[1], req1Debug);
2191
-
2192
- // But req-2 logs are still buffered
2193
- const req2Debug: LogRecord = {
2194
- ...debug,
2195
- properties: { requestId: "req-2" },
2196
- };
2197
- sink(req2Debug);
2198
- assertEquals(buffer.length, 2); // No change
2199
-
2200
- // Until req-2 triggers
2201
- const req2Error: LogRecord = {
2202
- ...error,
2203
- properties: { requestId: "req-2" },
2204
- };
2205
- sink(req2Error);
2206
- assertEquals(buffer.length, 4);
2207
- assertEquals(buffer[2], req2Debug);
2208
- assertEquals(buffer[3], req2Error);
2209
- });
2210
-
2211
- test("fingersCrossed() - TTL-based buffer cleanup", async () => {
2212
- const buffer: LogRecord[] = [];
2213
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2214
- isolateByContext: {
2215
- keys: ["requestId"],
2216
- bufferTtlMs: 100, // 100ms TTL
2217
- cleanupIntervalMs: 50, // cleanup every 50ms
2218
- },
2219
- }) as Sink & Disposable;
2220
-
2221
- try {
2222
- // Create records with different request IDs
2223
- const req1Record: LogRecord = {
2224
- ...debug,
2225
- properties: { requestId: "req-1" },
2226
- timestamp: Date.now(),
2227
- };
2228
- const req2Record: LogRecord = {
2229
- ...debug,
2230
- properties: { requestId: "req-2" },
2231
- timestamp: Date.now(),
2232
- };
2233
-
2234
- // Add records to buffers
2235
- sink(req1Record);
2236
- sink(req2Record);
2237
-
2238
- // Wait for TTL to expire and cleanup to run
2239
- await new Promise((resolve) => setTimeout(resolve, 200));
2240
-
2241
- // Add a new record after TTL expiry
2242
- const req3Record: LogRecord = {
2243
- ...debug,
2244
- properties: { requestId: "req-3" },
2245
- timestamp: Date.now(),
2246
- };
2247
- sink(req3Record);
2248
-
2249
- // Trigger an error for req-1 (should not flush expired req-1 buffer)
2250
- const req1Error: LogRecord = {
2251
- ...error,
2252
- properties: { requestId: "req-1" },
2253
- timestamp: Date.now(),
2254
- };
2255
- sink(req1Error);
2256
-
2257
- // Should only have req-1 error (req-1 debug was cleaned up by TTL)
2258
- assertEquals(buffer.length, 1);
2259
- assertEquals(buffer[0], req1Error);
2260
-
2261
- // Trigger an error for req-3 (should flush req-3 buffer)
2262
- buffer.length = 0; // Clear buffer
2263
- const req3Error: LogRecord = {
2264
- ...error,
2265
- properties: { requestId: "req-3" },
2266
- timestamp: Date.now(),
2267
- };
2268
- sink(req3Error);
2269
-
2270
- // Should have both req-3 debug and error
2271
- assertEquals(buffer.length, 2);
2272
- assertEquals(buffer[0], req3Record);
2273
- assertEquals(buffer[1], req3Error);
2274
- } finally {
2275
- // Clean up timer
2276
- sink[Symbol.dispose]();
2277
- }
2278
- });
2279
-
2280
- test("fingersCrossed() - TTL disabled when bufferTtlMs is zero", () => {
2281
- const buffer: LogRecord[] = [];
2282
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2283
- isolateByContext: {
2284
- keys: ["requestId"],
2285
- bufferTtlMs: 0, // TTL disabled
2286
- },
2287
- });
2288
-
2289
- // Should return a regular sink without disposal functionality
2290
- assertEquals("dispose" in sink, false);
2291
-
2292
- // Add a record
2293
- const record: LogRecord = {
2294
- ...debug,
2295
- properties: { requestId: "req-1" },
2296
- };
2297
- sink(record);
2298
-
2299
- // Trigger should work normally
2300
- const errorRecord: LogRecord = {
2301
- ...error,
2302
- properties: { requestId: "req-1" },
2303
- };
2304
- sink(errorRecord);
2305
-
2306
- assertEquals(buffer.length, 2);
2307
- assertEquals(buffer[0], record);
2308
- assertEquals(buffer[1], errorRecord);
2309
- });
2310
-
2311
- test("fingersCrossed() - TTL disabled when bufferTtlMs is undefined", () => {
2312
- const buffer: LogRecord[] = [];
2313
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2314
- isolateByContext: {
2315
- keys: ["requestId"],
2316
- // bufferTtlMs not specified
2317
- },
2318
- });
2319
-
2320
- // Should return a regular sink without disposal functionality
2321
- assertEquals("dispose" in sink, false);
2322
- });
2323
-
2324
- test("fingersCrossed() - LRU-based buffer eviction", () => {
2325
- const buffer: LogRecord[] = [];
2326
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2327
- isolateByContext: {
2328
- keys: ["requestId"],
2329
- maxContexts: 2, // Only keep 2 context buffers
2330
- },
2331
- });
2332
-
2333
- // Step 1: Add req-1
2334
- const req1Record: LogRecord = {
2335
- ...debug,
2336
- properties: { requestId: "req-1" },
2337
- };
2338
- sink(req1Record);
2339
-
2340
- // Step 2: Add req-2
2341
- const req2Record: LogRecord = {
2342
- ...debug,
2343
- properties: { requestId: "req-2" },
2344
- };
2345
- sink(req2Record);
2346
-
2347
- // Step 3: Add req-3 (should evict req-1)
2348
- const req3Record: LogRecord = {
2349
- ...debug,
2350
- properties: { requestId: "req-3" },
2351
- };
2352
- sink(req3Record);
2353
-
2354
- // Test req-1 was evicted by triggering error
2355
- const req1Error: LogRecord = {
2356
- ...error,
2357
- properties: { requestId: "req-1" },
2358
- };
2359
- sink(req1Error);
2360
-
2361
- // If req-1 was evicted, should only have error (length=1)
2362
- // If req-1 wasn't evicted, should have debug+error (length=2)
2363
- assertEquals(buffer.length, 1, "req-1 should have been evicted by LRU");
2364
- assertEquals(buffer[0], req1Error);
2365
- });
2366
-
2367
- test("fingersCrossed() - LRU eviction order with access updates", async () => {
2368
- const buffer: LogRecord[] = [];
2369
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2370
- isolateByContext: {
2371
- keys: ["requestId"],
2372
- maxContexts: 2,
2373
- },
2374
- });
2375
-
2376
- // Add two contexts with time gap to ensure different timestamps
2377
- const req1Record: LogRecord = {
2378
- ...debug,
2379
- properties: { requestId: "req-1" },
2380
- };
2381
- sink(req1Record); // req-1 is oldest
2382
-
2383
- // Small delay to ensure different lastAccess times
2384
- await new Promise((resolve) => setTimeout(resolve, 1));
2385
-
2386
- const req2Record: LogRecord = {
2387
- ...debug,
2388
- properties: { requestId: "req-2" },
2389
- };
2390
- sink(req2Record); // req-2 is newest
2391
-
2392
- // Access req-1 again after another delay to make it more recent
2393
- await new Promise((resolve) => setTimeout(resolve, 1));
2394
-
2395
- const req1Second: LogRecord = {
2396
- ...debug,
2397
- properties: { requestId: "req-1" },
2398
- };
2399
- sink(req1Second); // Now req-2 is oldest, req-1 is newest
2400
-
2401
- // Add third context - should evict req-2 (now the oldest)
2402
- const req3Record: LogRecord = {
2403
- ...debug,
2404
- properties: { requestId: "req-3" },
2405
- };
2406
- sink(req3Record);
2407
-
2408
- // Verify req-2 was evicted
2409
- const req2Error: LogRecord = {
2410
- ...error,
2411
- properties: { requestId: "req-2" },
2412
- };
2413
- sink(req2Error);
2414
-
2415
- // Should only have error record (no buffered records)
2416
- assertEquals(buffer.length, 1, "req-2 should have been evicted");
2417
- assertEquals(buffer[0], req2Error);
2418
- });
2419
-
2420
- test("fingersCrossed() - LRU disabled when maxContexts is zero", () => {
2421
- const buffer: LogRecord[] = [];
2422
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2423
- isolateByContext: {
2424
- keys: ["requestId"],
2425
- maxContexts: 0, // LRU disabled
2426
- },
2427
- });
2428
-
2429
- // Create many contexts - should not be limited
2430
- for (let i = 0; i < 100; i++) {
2431
- const record: LogRecord = {
2432
- ...debug,
2433
- properties: { requestId: `req-${i}` },
2434
- };
2435
- sink(record);
2436
- }
2437
-
2438
- // Trigger the last context
2439
- const errorRecord: LogRecord = {
2440
- ...error,
2441
- properties: { requestId: "req-99" },
2442
- };
2443
- sink(errorRecord);
2444
-
2445
- // Should have both debug and error records
2446
- assertEquals(buffer.length, 2);
2447
- assertEquals(buffer[0].properties?.requestId, "req-99");
2448
- assertEquals(buffer[1], errorRecord);
2449
- });
2450
-
2451
- test("fingersCrossed() - LRU disabled when maxContexts is undefined", () => {
2452
- const buffer: LogRecord[] = [];
2453
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2454
- isolateByContext: {
2455
- keys: ["requestId"],
2456
- // maxContexts not specified
2457
- },
2458
- });
2459
-
2460
- // Should work normally without LRU limits
2461
- const record: LogRecord = {
2462
- ...debug,
2463
- properties: { requestId: "req-1" },
2464
- };
2465
- sink(record);
2466
-
2467
- const errorRecord: LogRecord = {
2468
- ...error,
2469
- properties: { requestId: "req-1" },
2470
- };
2471
- sink(errorRecord);
2472
-
2473
- assertEquals(buffer.length, 2);
2474
- assertEquals(buffer[0], record);
2475
- assertEquals(buffer[1], errorRecord);
2476
- });
2477
-
2478
- test("fingersCrossed() - Combined TTL and LRU functionality", async () => {
2479
- const buffer: LogRecord[] = [];
2480
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2481
- isolateByContext: {
2482
- keys: ["requestId"],
2483
- maxContexts: 2, // LRU limit
2484
- bufferTtlMs: 100, // TTL limit
2485
- cleanupIntervalMs: 50, // cleanup interval
2486
- },
2487
- }) as Sink & Disposable;
2488
-
2489
- try {
2490
- // Create records for multiple contexts
2491
- const req1Record: LogRecord = {
2492
- ...debug,
2493
- properties: { requestId: "req-1" },
2494
- timestamp: Date.now(),
2495
- };
2496
- const req2Record: LogRecord = {
2497
- ...debug,
2498
- properties: { requestId: "req-2" },
2499
- timestamp: Date.now(),
2500
- };
2501
-
2502
- // Add two contexts (within LRU limit)
2503
- sink(req1Record);
2504
- sink(req2Record);
2505
-
2506
- // Wait for TTL to expire
2507
- await new Promise((resolve) => setTimeout(resolve, 150));
2508
-
2509
- // Add a third context (should work because TTL cleaned up old ones)
2510
- const req3Record: LogRecord = {
2511
- ...debug,
2512
- properties: { requestId: "req-3" },
2513
- timestamp: Date.now(),
2514
- };
2515
- sink(req3Record);
2516
-
2517
- // Trigger req-1 (should not find buffered records due to TTL expiry)
2518
- const req1Error: LogRecord = {
2519
- ...error,
2520
- properties: { requestId: "req-1" },
2521
- timestamp: Date.now(),
2522
- };
2523
- sink(req1Error);
2524
-
2525
- // Should only have the error record
2526
- assertEquals(buffer.length, 1);
2527
- assertEquals(buffer[0], req1Error);
2528
-
2529
- // Clear buffer and trigger req-3 (should have recent record)
2530
- buffer.length = 0;
2531
- const req3Error: LogRecord = {
2532
- ...error,
2533
- properties: { requestId: "req-3" },
2534
- timestamp: Date.now(),
2535
- };
2536
- sink(req3Error);
2537
-
2538
- // Should have both debug and error records
2539
- assertEquals(buffer.length, 2);
2540
- assertEquals(buffer[0], req3Record);
2541
- assertEquals(buffer[1], req3Error);
2542
- } finally {
2543
- sink[Symbol.dispose]();
2544
- }
2545
- });
2546
-
2547
- test("fingersCrossed() - LRU priority over TTL for active contexts", () => {
2548
- const buffer: LogRecord[] = [];
2549
- const sink = fingersCrossed(buffer.push.bind(buffer), {
2550
- isolateByContext: {
2551
- keys: ["requestId"],
2552
- maxContexts: 2,
2553
- bufferTtlMs: 10000, // Long TTL (10 seconds)
2554
- },
2555
- }) as Sink & Disposable;
2556
-
2557
- try {
2558
- // Create 3 contexts quickly (before TTL expires)
2559
- const req1Record: LogRecord = {
2560
- ...debug,
2561
- properties: { requestId: "req-1" },
2562
- };
2563
- const req2Record: LogRecord = {
2564
- ...debug,
2565
- properties: { requestId: "req-2" },
2566
- };
2567
- const req3Record: LogRecord = {
2568
- ...debug,
2569
- properties: { requestId: "req-3" },
2570
- };
2571
-
2572
- sink(req1Record); // LRU position: oldest
2573
- sink(req2Record); // LRU position: middle
2574
- sink(req3Record); // LRU position: newest, should evict req-1 due to LRU
2575
-
2576
- // Now trigger req-2 (should have buffered record)
2577
- const req2Error: LogRecord = {
2578
- ...error,
2579
- properties: { requestId: "req-2" },
2580
- };
2581
- sink(req2Error);
2582
-
2583
- // Should have both debug and error records
2584
- assertEquals(buffer.length, 2);
2585
- assertEquals(buffer[0], req2Record);
2586
- assertEquals(buffer[1], req2Error);
2587
- } finally {
2588
- sink[Symbol.dispose]();
2589
- }
2590
- });