@logtape/logtape 1.1.0-dev.338 → 1.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/deno.json +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/mod.cjs +1 -0
- package/dist/mod.d.cts +2 -2
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +2 -2
- package/dist/sink.cjs +136 -0
- package/dist/sink.d.cts +74 -1
- package/dist/sink.d.cts.map +1 -1
- package/dist/sink.d.ts +74 -1
- package/dist/sink.d.ts.map +1 -1
- package/dist/sink.js +136 -1
- package/dist/sink.js.map +1 -1
- package/package.json +1 -1
- package/src/mod.ts +2 -0
- package/src/sink.test.ts +833 -0
- package/src/sink.ts +269 -1
package/src/sink.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
defaultTextFormatter,
|
|
6
6
|
type TextFormatter,
|
|
7
7
|
} from "./formatter.ts";
|
|
8
|
-
import type
|
|
8
|
+
import { compareLogLevel, type LogLevel } from "./level.ts";
|
|
9
9
|
import type { LogRecord } from "./record.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -445,3 +445,271 @@ export function fromAsyncSink(asyncSink: AsyncSink): Sink & AsyncDisposable {
|
|
|
445
445
|
};
|
|
446
446
|
return sink;
|
|
447
447
|
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Options for the {@link fingersCrossed} function.
|
|
451
|
+
* @since 1.1.0
|
|
452
|
+
*/
|
|
453
|
+
export interface FingersCrossedOptions {
|
|
454
|
+
/**
|
|
455
|
+
* Minimum log level that triggers buffer flush.
|
|
456
|
+
* When a log record at or above this level is received, all buffered
|
|
457
|
+
* records are flushed to the wrapped sink.
|
|
458
|
+
* @default `"error"`
|
|
459
|
+
*/
|
|
460
|
+
readonly triggerLevel?: LogLevel;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Maximum buffer size before oldest records are dropped.
|
|
464
|
+
* When the buffer exceeds this size, the oldest records are removed
|
|
465
|
+
* to prevent unbounded memory growth.
|
|
466
|
+
* @default `1000`
|
|
467
|
+
*/
|
|
468
|
+
readonly maxBufferSize?: number;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Category isolation mode or custom matcher function.
|
|
472
|
+
*
|
|
473
|
+
* When `undefined` (default), all log records share a single buffer.
|
|
474
|
+
*
|
|
475
|
+
* When set to a mode string:
|
|
476
|
+
*
|
|
477
|
+
* - `"descendant"`: Flush child category buffers when parent triggers
|
|
478
|
+
* - `"ancestor"`: Flush parent category buffers when child triggers
|
|
479
|
+
* - `"both"`: Flush both parent and child category buffers
|
|
480
|
+
*
|
|
481
|
+
* When set to a function, it receives the trigger category and buffered
|
|
482
|
+
* category and should return true if the buffered category should be flushed.
|
|
483
|
+
*
|
|
484
|
+
* @default `undefined` (no isolation, single global buffer)
|
|
485
|
+
*/
|
|
486
|
+
readonly isolateByCategory?:
|
|
487
|
+
| "descendant"
|
|
488
|
+
| "ancestor"
|
|
489
|
+
| "both"
|
|
490
|
+
| ((
|
|
491
|
+
triggerCategory: readonly string[],
|
|
492
|
+
bufferedCategory: readonly string[],
|
|
493
|
+
) => boolean);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Creates a sink that buffers log records until a trigger level is reached.
|
|
498
|
+
* This pattern, known as "fingers crossed" logging, keeps detailed debug logs
|
|
499
|
+
* in memory and only outputs them when an error or other significant event occurs.
|
|
500
|
+
*
|
|
501
|
+
* @example Basic usage with default settings
|
|
502
|
+
* ```typescript
|
|
503
|
+
* const sink = fingersCrossed(getConsoleSink());
|
|
504
|
+
* // Debug and info logs are buffered
|
|
505
|
+
* // When an error occurs, all buffered logs + the error are output
|
|
506
|
+
* ```
|
|
507
|
+
*
|
|
508
|
+
* @example Custom trigger level and buffer size
|
|
509
|
+
* ```typescript
|
|
510
|
+
* const sink = fingersCrossed(getConsoleSink(), {
|
|
511
|
+
* triggerLevel: "warning", // Trigger on warning or higher
|
|
512
|
+
* maxBufferSize: 500 // Keep last 500 records
|
|
513
|
+
* });
|
|
514
|
+
* ```
|
|
515
|
+
*
|
|
516
|
+
* @example Category isolation
|
|
517
|
+
* ```typescript
|
|
518
|
+
* const sink = fingersCrossed(getConsoleSink(), {
|
|
519
|
+
* isolateByCategory: "descendant" // Separate buffers per category
|
|
520
|
+
* });
|
|
521
|
+
* // Error in ["app"] triggers flush of ["app"] and ["app", "module"] buffers
|
|
522
|
+
* // But not ["other"] buffer
|
|
523
|
+
* ```
|
|
524
|
+
*
|
|
525
|
+
* @param sink The sink to wrap. Buffered records are sent to this sink when
|
|
526
|
+
* triggered.
|
|
527
|
+
* @param options Configuration options for the fingers crossed behavior.
|
|
528
|
+
* @returns A sink that buffers records until the trigger level is reached.
|
|
529
|
+
* @since 1.1.0
|
|
530
|
+
*/
|
|
531
|
+
export function fingersCrossed(
|
|
532
|
+
sink: Sink,
|
|
533
|
+
options: FingersCrossedOptions = {},
|
|
534
|
+
): Sink {
|
|
535
|
+
const triggerLevel = options.triggerLevel ?? "error";
|
|
536
|
+
const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1000);
|
|
537
|
+
const isolateByCategory = options.isolateByCategory;
|
|
538
|
+
|
|
539
|
+
// Validate trigger level early
|
|
540
|
+
try {
|
|
541
|
+
compareLogLevel("trace", triggerLevel); // Test with any valid level
|
|
542
|
+
} catch (error) {
|
|
543
|
+
throw new TypeError(
|
|
544
|
+
`Invalid triggerLevel: ${JSON.stringify(triggerLevel)}. ${
|
|
545
|
+
error instanceof Error ? error.message : String(error)
|
|
546
|
+
}`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Helper functions for category matching
|
|
551
|
+
function isDescendant(
|
|
552
|
+
parent: readonly string[],
|
|
553
|
+
child: readonly string[],
|
|
554
|
+
): boolean {
|
|
555
|
+
if (parent.length === 0 || child.length === 0) return false; // Empty categories are isolated
|
|
556
|
+
if (parent.length > child.length) return false;
|
|
557
|
+
return parent.every((p, i) => p === child[i]);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isAncestor(
|
|
561
|
+
child: readonly string[],
|
|
562
|
+
parent: readonly string[],
|
|
563
|
+
): boolean {
|
|
564
|
+
if (child.length === 0 || parent.length === 0) return false; // Empty categories are isolated
|
|
565
|
+
if (child.length < parent.length) return false;
|
|
566
|
+
return parent.every((p, i) => p === child[i]);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Determine matcher function based on isolation mode
|
|
570
|
+
let shouldFlushBuffer:
|
|
571
|
+
| ((
|
|
572
|
+
triggerCategory: readonly string[],
|
|
573
|
+
bufferedCategory: readonly string[],
|
|
574
|
+
) => boolean)
|
|
575
|
+
| null = null;
|
|
576
|
+
|
|
577
|
+
if (isolateByCategory) {
|
|
578
|
+
if (typeof isolateByCategory === "function") {
|
|
579
|
+
shouldFlushBuffer = isolateByCategory;
|
|
580
|
+
} else {
|
|
581
|
+
switch (isolateByCategory) {
|
|
582
|
+
case "descendant":
|
|
583
|
+
shouldFlushBuffer = (trigger, buffered) =>
|
|
584
|
+
isDescendant(trigger, buffered);
|
|
585
|
+
break;
|
|
586
|
+
case "ancestor":
|
|
587
|
+
shouldFlushBuffer = (trigger, buffered) =>
|
|
588
|
+
isAncestor(trigger, buffered);
|
|
589
|
+
break;
|
|
590
|
+
case "both":
|
|
591
|
+
shouldFlushBuffer = (trigger, buffered) =>
|
|
592
|
+
isDescendant(trigger, buffered) || isAncestor(trigger, buffered);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Helper functions for category serialization
|
|
599
|
+
function getCategoryKey(category: readonly string[]): string {
|
|
600
|
+
return JSON.stringify(category);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function parseCategoryKey(key: string): string[] {
|
|
604
|
+
return JSON.parse(key);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Buffer management
|
|
608
|
+
if (!isolateByCategory) {
|
|
609
|
+
// Single global buffer
|
|
610
|
+
const buffer: LogRecord[] = [];
|
|
611
|
+
let triggered = false;
|
|
612
|
+
|
|
613
|
+
return (record: LogRecord) => {
|
|
614
|
+
if (triggered) {
|
|
615
|
+
// Already triggered, pass through directly
|
|
616
|
+
sink(record);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Check if this record triggers flush
|
|
621
|
+
if (compareLogLevel(record.level, triggerLevel) >= 0) {
|
|
622
|
+
triggered = true;
|
|
623
|
+
|
|
624
|
+
// Flush buffer
|
|
625
|
+
for (const bufferedRecord of buffer) {
|
|
626
|
+
sink(bufferedRecord);
|
|
627
|
+
}
|
|
628
|
+
buffer.length = 0;
|
|
629
|
+
|
|
630
|
+
// Send trigger record
|
|
631
|
+
sink(record);
|
|
632
|
+
} else {
|
|
633
|
+
// Buffer the record
|
|
634
|
+
buffer.push(record);
|
|
635
|
+
|
|
636
|
+
// Enforce max buffer size
|
|
637
|
+
while (buffer.length > maxBufferSize) {
|
|
638
|
+
buffer.shift();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
} else {
|
|
643
|
+
// Category-isolated buffers
|
|
644
|
+
const buffers = new Map<string, LogRecord[]>();
|
|
645
|
+
const triggered = new Set<string>();
|
|
646
|
+
|
|
647
|
+
return (record: LogRecord) => {
|
|
648
|
+
const categoryKey = getCategoryKey(record.category);
|
|
649
|
+
|
|
650
|
+
// Check if this category is already triggered
|
|
651
|
+
if (triggered.has(categoryKey)) {
|
|
652
|
+
sink(record);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Check if this record triggers flush
|
|
657
|
+
if (compareLogLevel(record.level, triggerLevel) >= 0) {
|
|
658
|
+
// Find all buffers that should be flushed
|
|
659
|
+
const keysToFlush = new Set<string>();
|
|
660
|
+
|
|
661
|
+
for (const [bufferedKey] of buffers) {
|
|
662
|
+
if (bufferedKey === categoryKey) {
|
|
663
|
+
keysToFlush.add(bufferedKey);
|
|
664
|
+
} else if (shouldFlushBuffer) {
|
|
665
|
+
const bufferedCategory = parseCategoryKey(bufferedKey);
|
|
666
|
+
try {
|
|
667
|
+
if (shouldFlushBuffer(record.category, bufferedCategory)) {
|
|
668
|
+
keysToFlush.add(bufferedKey);
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
// Ignore errors from custom matcher
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Flush matching buffers
|
|
677
|
+
const allRecordsToFlush: LogRecord[] = [];
|
|
678
|
+
for (const key of keysToFlush) {
|
|
679
|
+
const buffer = buffers.get(key);
|
|
680
|
+
if (buffer) {
|
|
681
|
+
allRecordsToFlush.push(...buffer);
|
|
682
|
+
buffers.delete(key);
|
|
683
|
+
triggered.add(key);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Sort by timestamp to maintain chronological order
|
|
688
|
+
allRecordsToFlush.sort((a, b) => a.timestamp - b.timestamp);
|
|
689
|
+
|
|
690
|
+
// Flush all records
|
|
691
|
+
for (const bufferedRecord of allRecordsToFlush) {
|
|
692
|
+
sink(bufferedRecord);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Mark trigger category as triggered and send trigger record
|
|
696
|
+
triggered.add(categoryKey);
|
|
697
|
+
sink(record);
|
|
698
|
+
} else {
|
|
699
|
+
// Buffer the record
|
|
700
|
+
let buffer = buffers.get(categoryKey);
|
|
701
|
+
if (!buffer) {
|
|
702
|
+
buffer = [];
|
|
703
|
+
buffers.set(categoryKey, buffer);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
buffer.push(record);
|
|
707
|
+
|
|
708
|
+
// Enforce max buffer size per category
|
|
709
|
+
while (buffer.length > maxBufferSize) {
|
|
710
|
+
buffer.shift();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
}
|