@logtape/logtape 1.2.0-dev.343 → 1.2.0-dev.347

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 CHANGED
@@ -2207,3 +2207,384 @@ test("fingersCrossed() - context isolation after trigger", () => {
2207
2207
  assertEquals(buffer[2], req2Debug);
2208
2208
  assertEquals(buffer[3], req2Error);
2209
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
+ });
package/src/sink.ts CHANGED
@@ -513,6 +513,17 @@ export interface FingersCrossedOptions {
513
513
  * })
514
514
  * ```
515
515
  *
516
+ * @example With TTL-based buffer cleanup
517
+ * ```typescript
518
+ * fingersCrossed(sink, {
519
+ * isolateByContext: {
520
+ * keys: ['requestId'],
521
+ * bufferTtlMs: 30000, // 30 seconds
522
+ * cleanupIntervalMs: 10000 // cleanup every 10 seconds
523
+ * }
524
+ * })
525
+ * ```
526
+ *
516
527
  * @default `undefined` (no context isolation)
517
528
  * @since 1.2.0
518
529
  */
@@ -522,9 +533,65 @@ export interface FingersCrossedOptions {
522
533
  * Buffers will be separate for different combinations of these context values.
523
534
  */
524
535
  readonly keys: readonly string[];
536
+
537
+ /**
538
+ * Maximum number of context buffers to maintain simultaneously.
539
+ * When this limit is exceeded, the least recently used (LRU) buffers
540
+ * will be evicted to make room for new ones.
541
+ *
542
+ * This provides memory protection in high-concurrency scenarios where
543
+ * many different context values might be active simultaneously.
544
+ *
545
+ * When set to 0 or undefined, no limit is enforced.
546
+ *
547
+ * @default `undefined` (no limit)
548
+ * @since 1.2.0
549
+ */
550
+ readonly maxContexts?: number;
551
+
552
+ /**
553
+ * Time-to-live for context buffers in milliseconds.
554
+ * Buffers that haven't been accessed for this duration will be automatically
555
+ * cleaned up to prevent memory leaks in long-running applications.
556
+ *
557
+ * When set to 0 or undefined, buffers will never expire based on time.
558
+ *
559
+ * @default `undefined` (no TTL)
560
+ * @since 1.2.0
561
+ */
562
+ readonly bufferTtlMs?: number;
563
+
564
+ /**
565
+ * Interval in milliseconds for running cleanup operations.
566
+ * The cleanup process removes expired buffers based on {@link bufferTtlMs}.
567
+ *
568
+ * This option is ignored if {@link bufferTtlMs} is not set.
569
+ *
570
+ * @default `30000` (30 seconds)
571
+ * @since 1.2.0
572
+ */
573
+ readonly cleanupIntervalMs?: number;
525
574
  };
526
575
  }
527
576
 
577
+ /**
578
+ * Metadata for context-based buffer tracking.
579
+ * Used internally by {@link fingersCrossed} to manage buffer lifecycle with LRU support.
580
+ * @since 1.2.0
581
+ */
582
+ interface BufferMetadata {
583
+ /**
584
+ * The actual log records buffer.
585
+ */
586
+ readonly buffer: LogRecord[];
587
+
588
+ /**
589
+ * Timestamp of the last access to this buffer (in milliseconds).
590
+ * Used for LRU-based eviction when {@link FingersCrossedOptions.isolateByContext.maxContexts} is set.
591
+ */
592
+ lastAccess: number;
593
+ }
594
+
528
595
  /**
529
596
  * Creates a sink that buffers log records until a trigger level is reached.
530
597
  * This pattern, known as "fingers crossed" logging, keeps detailed debug logs
@@ -563,12 +630,19 @@ export interface FingersCrossedOptions {
563
630
  export function fingersCrossed(
564
631
  sink: Sink,
565
632
  options: FingersCrossedOptions = {},
566
- ): Sink {
633
+ ): Sink | (Sink & Disposable) {
567
634
  const triggerLevel = options.triggerLevel ?? "error";
568
635
  const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1000);
569
636
  const isolateByCategory = options.isolateByCategory;
570
637
  const isolateByContext = options.isolateByContext;
571
638
 
639
+ // TTL and LRU configuration
640
+ const bufferTtlMs = isolateByContext?.bufferTtlMs;
641
+ const cleanupIntervalMs = isolateByContext?.cleanupIntervalMs ?? 30000;
642
+ const maxContexts = isolateByContext?.maxContexts;
643
+ const hasTtl = bufferTtlMs != null && bufferTtlMs > 0;
644
+ const hasLru = maxContexts != null && maxContexts > 0;
645
+
572
646
  // Validate trigger level early
573
647
  try {
574
648
  compareLogLevel("trace", triggerLevel); // Test with any valid level
@@ -685,6 +759,52 @@ export function fingersCrossed(
685
759
  return { category: parseCategoryKey(categoryPart), context: contextPart };
686
760
  }
687
761
 
762
+ // TTL-based cleanup function
763
+ function cleanupExpiredBuffers(buffers: Map<string, BufferMetadata>): void {
764
+ if (!hasTtl) return;
765
+
766
+ const now = Date.now();
767
+ const expiredKeys: string[] = [];
768
+
769
+ for (const [key, metadata] of buffers) {
770
+ if (metadata.buffer.length === 0) continue;
771
+
772
+ // Use the timestamp of the last (most recent) record in the buffer
773
+ const lastRecordTimestamp =
774
+ metadata.buffer[metadata.buffer.length - 1].timestamp;
775
+ if (now - lastRecordTimestamp > bufferTtlMs!) {
776
+ expiredKeys.push(key);
777
+ }
778
+ }
779
+
780
+ // Remove expired buffers
781
+ for (const key of expiredKeys) {
782
+ buffers.delete(key);
783
+ }
784
+ }
785
+
786
+ // LRU-based eviction function
787
+ function evictLruBuffers(
788
+ buffers: Map<string, BufferMetadata>,
789
+ numToEvict?: number,
790
+ ): void {
791
+ if (!hasLru) return;
792
+
793
+ // Use provided numToEvict or calculate based on current size vs limit
794
+ const toEvict = numToEvict ?? Math.max(0, buffers.size - maxContexts!);
795
+ if (toEvict <= 0) return;
796
+
797
+ // Sort by lastAccess timestamp (oldest first)
798
+ const sortedEntries = Array.from(buffers.entries())
799
+ .sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
800
+
801
+ // Remove the oldest buffers
802
+ for (let i = 0; i < toEvict; i++) {
803
+ const [key] = sortedEntries[i];
804
+ buffers.delete(key);
805
+ }
806
+ }
807
+
688
808
  // Buffer management
689
809
  if (!isolateByCategory && !isolateByContext) {
690
810
  // Single global buffer
@@ -722,10 +842,18 @@ export function fingersCrossed(
722
842
  };
723
843
  } else {
724
844
  // Category and/or context-isolated buffers
725
- const buffers = new Map<string, LogRecord[]>();
845
+ const buffers = new Map<string, BufferMetadata>();
726
846
  const triggered = new Set<string>();
727
847
 
728
- return (record: LogRecord) => {
848
+ // Set up TTL cleanup timer if enabled
849
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
850
+ if (hasTtl) {
851
+ cleanupTimer = setInterval(() => {
852
+ cleanupExpiredBuffers(buffers);
853
+ }, cleanupIntervalMs);
854
+ }
855
+
856
+ const fingersCrossedSink = (record: LogRecord) => {
729
857
  const bufferKey = getBufferKey(record.category, record.properties);
730
858
 
731
859
  // Check if this buffer is already triggered
@@ -783,9 +911,9 @@ export function fingersCrossed(
783
911
  // Flush matching buffers
784
912
  const allRecordsToFlush: LogRecord[] = [];
785
913
  for (const key of keysToFlush) {
786
- const buffer = buffers.get(key);
787
- if (buffer) {
788
- allRecordsToFlush.push(...buffer);
914
+ const metadata = buffers.get(key);
915
+ if (metadata) {
916
+ allRecordsToFlush.push(...metadata.buffer);
789
917
  buffers.delete(key);
790
918
  triggered.add(key);
791
919
  }
@@ -804,19 +932,45 @@ export function fingersCrossed(
804
932
  sink(record);
805
933
  } else {
806
934
  // Buffer the record
807
- let buffer = buffers.get(bufferKey);
808
- if (!buffer) {
809
- buffer = [];
810
- buffers.set(bufferKey, buffer);
935
+ const now = Date.now();
936
+ let metadata = buffers.get(bufferKey);
937
+ if (!metadata) {
938
+ // Apply LRU eviction if adding new buffer would exceed capacity
939
+ if (hasLru && buffers.size >= maxContexts!) {
940
+ // Calculate how many buffers to evict to make room for the new one
941
+ const numToEvict = buffers.size - maxContexts! + 1;
942
+ evictLruBuffers(buffers, numToEvict);
943
+ }
944
+
945
+ metadata = {
946
+ buffer: [],
947
+ lastAccess: now,
948
+ };
949
+ buffers.set(bufferKey, metadata);
950
+ } else {
951
+ // Update last access time for LRU
952
+ metadata.lastAccess = now;
811
953
  }
812
954
 
813
- buffer.push(record);
955
+ metadata.buffer.push(record);
814
956
 
815
957
  // Enforce max buffer size per buffer
816
- while (buffer.length > maxBufferSize) {
817
- buffer.shift();
958
+ while (metadata.buffer.length > maxBufferSize) {
959
+ metadata.buffer.shift();
818
960
  }
819
961
  }
820
962
  };
963
+
964
+ // Add disposal functionality to clean up timer
965
+ if (cleanupTimer !== null) {
966
+ (fingersCrossedSink as Sink & Disposable)[Symbol.dispose] = () => {
967
+ if (cleanupTimer !== null) {
968
+ clearInterval(cleanupTimer);
969
+ cleanupTimer = null;
970
+ }
971
+ };
972
+ }
973
+
974
+ return fingersCrossedSink;
821
975
  }
822
976
  }