@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/src/sink.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  defaultTextFormatter,
6
6
  type TextFormatter,
7
7
  } from "./formatter.ts";
8
- import type { LogLevel } from "./level.ts";
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
+ }