@logtape/logtape 1.1.4 → 1.1.5

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 DELETED
@@ -1,715 +0,0 @@
1
- import { type FilterLike, toFilter } from "./filter.ts";
2
- import {
3
- type ConsoleFormatter,
4
- defaultConsoleFormatter,
5
- defaultTextFormatter,
6
- type TextFormatter,
7
- } from "./formatter.ts";
8
- import { compareLogLevel, type LogLevel } from "./level.ts";
9
- import type { LogRecord } from "./record.ts";
10
-
11
- /**
12
- * A sink is a function that accepts a log record and prints it somewhere.
13
- * Thrown exceptions will be suppressed and then logged to the meta logger,
14
- * a {@link Logger} with the category `["logtape", "meta"]`. (In that case,
15
- * the meta log record will not be passed to the sink to avoid infinite
16
- * recursion.)
17
- *
18
- * @param record The log record to sink.
19
- */
20
- export type Sink = (record: LogRecord) => void;
21
-
22
- /**
23
- * An async sink is a function that accepts a log record and asynchronously
24
- * processes it. This type is used with {@link fromAsyncSink} to create
25
- * a regular sink that properly handles asynchronous operations.
26
- *
27
- * @param record The log record to process asynchronously.
28
- * @returns A promise that resolves when the record has been processed.
29
- * @since 1.0.0
30
- */
31
- export type AsyncSink = (record: LogRecord) => Promise<void>;
32
-
33
- /**
34
- * Turns a sink into a filtered sink. The returned sink only logs records that
35
- * pass the filter.
36
- *
37
- * @example Filter a console sink to only log records with the info level
38
- * ```typescript
39
- * const sink = withFilter(getConsoleSink(), "info");
40
- * ```
41
- *
42
- * @param sink A sink to be filtered.
43
- * @param filter A filter to apply to the sink. It can be either a filter
44
- * function or a {@link LogLevel} string.
45
- * @returns A sink that only logs records that pass the filter.
46
- */
47
- export function withFilter(sink: Sink, filter: FilterLike): Sink {
48
- const filterFunc = toFilter(filter);
49
- return (record: LogRecord) => {
50
- if (filterFunc(record)) sink(record);
51
- };
52
- }
53
-
54
- /**
55
- * Options for the {@link getStreamSink} function.
56
- */
57
- export interface StreamSinkOptions {
58
- /**
59
- * The text formatter to use. Defaults to {@link defaultTextFormatter}.
60
- */
61
- formatter?: TextFormatter;
62
-
63
- /**
64
- * The text encoder to use. Defaults to an instance of {@link TextEncoder}.
65
- */
66
- encoder?: { encode(text: string): Uint8Array };
67
-
68
- /**
69
- * Enable non-blocking mode with optional buffer configuration.
70
- * When enabled, log records are buffered and flushed in the background.
71
- *
72
- * @example Simple non-blocking mode
73
- * ```typescript
74
- * getStreamSink(stream, { nonBlocking: true });
75
- * ```
76
- *
77
- * @example Custom buffer configuration
78
- * ```typescript
79
- * getStreamSink(stream, {
80
- * nonBlocking: {
81
- * bufferSize: 1000,
82
- * flushInterval: 50
83
- * }
84
- * });
85
- * ```
86
- *
87
- * @default `false`
88
- * @since 1.0.0
89
- */
90
- nonBlocking?: boolean | {
91
- /**
92
- * Maximum number of records to buffer before flushing.
93
- * @default `100`
94
- */
95
- bufferSize?: number;
96
-
97
- /**
98
- * Interval in milliseconds between automatic flushes.
99
- * @default `100`
100
- */
101
- flushInterval?: number;
102
- };
103
- }
104
-
105
- /**
106
- * A factory that returns a sink that writes to a {@link WritableStream}.
107
- *
108
- * Note that the `stream` is of Web Streams API, which is different from
109
- * Node.js streams. You can convert a Node.js stream to a Web Streams API
110
- * stream using [`stream.Writable.toWeb()`] method.
111
- *
112
- * [`stream.Writable.toWeb()`]: https://nodejs.org/api/stream.html#streamwritabletowebstreamwritable
113
- *
114
- * @example Sink to the standard error in Deno
115
- * ```typescript
116
- * const stderrSink = getStreamSink(Deno.stderr.writable);
117
- * ```
118
- *
119
- * @example Sink to the standard error in Node.js
120
- * ```typescript
121
- * import stream from "node:stream";
122
- * const stderrSink = getStreamSink(stream.Writable.toWeb(process.stderr));
123
- * ```
124
- *
125
- * @param stream The stream to write to.
126
- * @param options The options for the sink.
127
- * @returns A sink that writes to the stream.
128
- */
129
- export function getStreamSink(
130
- stream: WritableStream,
131
- options: StreamSinkOptions = {},
132
- ): Sink & AsyncDisposable {
133
- const formatter = options.formatter ?? defaultTextFormatter;
134
- const encoder = options.encoder ?? new TextEncoder();
135
- const writer = stream.getWriter();
136
-
137
- if (!options.nonBlocking) {
138
- let lastPromise = Promise.resolve();
139
- const sink: Sink & AsyncDisposable = (record: LogRecord) => {
140
- const bytes = encoder.encode(formatter(record));
141
- lastPromise = lastPromise
142
- .then(() => writer.ready)
143
- .then(() => writer.write(bytes));
144
- };
145
- sink[Symbol.asyncDispose] = async () => {
146
- await lastPromise;
147
- await writer.close();
148
- };
149
- return sink;
150
- }
151
-
152
- // Non-blocking mode implementation
153
- const nonBlockingConfig = options.nonBlocking === true
154
- ? {}
155
- : options.nonBlocking;
156
- const bufferSize = nonBlockingConfig.bufferSize ?? 100;
157
- const flushInterval = nonBlockingConfig.flushInterval ?? 100;
158
-
159
- const buffer: LogRecord[] = [];
160
- let flushTimer: ReturnType<typeof setInterval> | null = null;
161
- let disposed = false;
162
- let activeFlush: Promise<void> | null = null;
163
- const maxBufferSize = bufferSize * 2; // Overflow protection
164
-
165
- async function flush(): Promise<void> {
166
- if (buffer.length === 0) return;
167
-
168
- const records = buffer.splice(0);
169
- for (const record of records) {
170
- try {
171
- const bytes = encoder.encode(formatter(record));
172
- await writer.ready;
173
- await writer.write(bytes);
174
- } catch {
175
- // Silently ignore errors in non-blocking mode to avoid disrupting the application
176
- }
177
- }
178
- }
179
-
180
- function scheduleFlush(): void {
181
- if (activeFlush) return;
182
-
183
- activeFlush = flush().finally(() => {
184
- activeFlush = null;
185
- });
186
- }
187
-
188
- function startFlushTimer(): void {
189
- if (flushTimer !== null || disposed) return;
190
-
191
- flushTimer = setInterval(() => {
192
- scheduleFlush();
193
- }, flushInterval);
194
- }
195
-
196
- const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {
197
- if (disposed) return;
198
-
199
- // Buffer overflow protection: drop oldest records if buffer is too large
200
- if (buffer.length >= maxBufferSize) {
201
- buffer.shift(); // Remove oldest record
202
- }
203
-
204
- buffer.push(record);
205
-
206
- if (buffer.length >= bufferSize) {
207
- scheduleFlush();
208
- } else if (flushTimer === null) {
209
- startFlushTimer();
210
- }
211
- };
212
-
213
- nonBlockingSink[Symbol.asyncDispose] = async () => {
214
- disposed = true;
215
- if (flushTimer !== null) {
216
- clearInterval(flushTimer);
217
- flushTimer = null;
218
- }
219
- await flush();
220
- try {
221
- await writer.close();
222
- } catch {
223
- // Writer might already be closed or errored
224
- }
225
- };
226
-
227
- return nonBlockingSink;
228
- }
229
-
230
- type ConsoleMethod = "debug" | "info" | "log" | "warn" | "error";
231
-
232
- /**
233
- * Options for the {@link getConsoleSink} function.
234
- */
235
- export interface ConsoleSinkOptions {
236
- /**
237
- * The console formatter or text formatter to use.
238
- * Defaults to {@link defaultConsoleFormatter}.
239
- */
240
- formatter?: ConsoleFormatter | TextFormatter;
241
-
242
- /**
243
- * The mapping from log levels to console methods. Defaults to:
244
- *
245
- * ```typescript
246
- * {
247
- * trace: "trace",
248
- * debug: "debug",
249
- * info: "info",
250
- * warning: "warn",
251
- * error: "error",
252
- * fatal: "error",
253
- * }
254
- * ```
255
- * @since 0.9.0
256
- */
257
- levelMap?: Record<LogLevel, ConsoleMethod>;
258
-
259
- /**
260
- * The console to log to. Defaults to {@link console}.
261
- */
262
- console?: Console;
263
-
264
- /**
265
- * Enable non-blocking mode with optional buffer configuration.
266
- * When enabled, log records are buffered and flushed in the background.
267
- *
268
- * @example Simple non-blocking mode
269
- * ```typescript
270
- * getConsoleSink({ nonBlocking: true });
271
- * ```
272
- *
273
- * @example Custom buffer configuration
274
- * ```typescript
275
- * getConsoleSink({
276
- * nonBlocking: {
277
- * bufferSize: 1000,
278
- * flushInterval: 50
279
- * }
280
- * });
281
- * ```
282
- *
283
- * @default `false`
284
- * @since 1.0.0
285
- */
286
- nonBlocking?: boolean | {
287
- /**
288
- * Maximum number of records to buffer before flushing.
289
- * @default `100`
290
- */
291
- bufferSize?: number;
292
-
293
- /**
294
- * Interval in milliseconds between automatic flushes.
295
- * @default `100`
296
- */
297
- flushInterval?: number;
298
- };
299
- }
300
-
301
- /**
302
- * A console sink factory that returns a sink that logs to the console.
303
- *
304
- * @param options The options for the sink.
305
- * @returns A sink that logs to the console. If `nonBlocking` is enabled,
306
- * returns a sink that also implements {@link Disposable}.
307
- */
308
- export function getConsoleSink(
309
- options: ConsoleSinkOptions = {},
310
- ): Sink | (Sink & Disposable) {
311
- const formatter = options.formatter ?? defaultConsoleFormatter;
312
- const levelMap: Record<LogLevel, ConsoleMethod> = {
313
- trace: "debug",
314
- debug: "debug",
315
- info: "info",
316
- warning: "warn",
317
- error: "error",
318
- fatal: "error",
319
- ...(options.levelMap ?? {}),
320
- };
321
- const console = options.console ?? globalThis.console;
322
-
323
- const baseSink = (record: LogRecord) => {
324
- const args = formatter(record);
325
- const method = levelMap[record.level];
326
- if (method === undefined) {
327
- throw new TypeError(`Invalid log level: ${record.level}.`);
328
- }
329
- if (typeof args === "string") {
330
- const msg = args.replace(/\r?\n$/, "");
331
- console[method](msg);
332
- } else {
333
- console[method](...args);
334
- }
335
- };
336
-
337
- if (!options.nonBlocking) {
338
- return baseSink;
339
- }
340
-
341
- // Non-blocking mode implementation
342
- const nonBlockingConfig = options.nonBlocking === true
343
- ? {}
344
- : options.nonBlocking;
345
- const bufferSize = nonBlockingConfig.bufferSize ?? 100;
346
- const flushInterval = nonBlockingConfig.flushInterval ?? 100;
347
-
348
- const buffer: LogRecord[] = [];
349
- let flushTimer: ReturnType<typeof setInterval> | null = null;
350
- let disposed = false;
351
- let flushScheduled = false;
352
- const maxBufferSize = bufferSize * 2; // Overflow protection
353
-
354
- function flush(): void {
355
- if (buffer.length === 0) return;
356
-
357
- const records = buffer.splice(0);
358
- for (const record of records) {
359
- try {
360
- baseSink(record);
361
- } catch {
362
- // Silently ignore errors in non-blocking mode to avoid disrupting the application
363
- }
364
- }
365
- }
366
-
367
- function scheduleFlush(): void {
368
- if (flushScheduled) return;
369
-
370
- flushScheduled = true;
371
- setTimeout(() => {
372
- flushScheduled = false;
373
- flush();
374
- }, 0);
375
- }
376
-
377
- function startFlushTimer(): void {
378
- if (flushTimer !== null || disposed) return;
379
-
380
- flushTimer = setInterval(() => {
381
- flush();
382
- }, flushInterval);
383
- }
384
-
385
- const nonBlockingSink: Sink & Disposable = (record: LogRecord) => {
386
- if (disposed) return;
387
-
388
- // Buffer overflow protection: drop oldest records if buffer is too large
389
- if (buffer.length >= maxBufferSize) {
390
- buffer.shift(); // Remove oldest record
391
- }
392
-
393
- buffer.push(record);
394
-
395
- if (buffer.length >= bufferSize) {
396
- scheduleFlush();
397
- } else if (flushTimer === null) {
398
- startFlushTimer();
399
- }
400
- };
401
-
402
- nonBlockingSink[Symbol.dispose] = () => {
403
- disposed = true;
404
- if (flushTimer !== null) {
405
- clearInterval(flushTimer);
406
- flushTimer = null;
407
- }
408
- flush();
409
- };
410
-
411
- return nonBlockingSink;
412
- }
413
-
414
- /**
415
- * Converts an async sink into a regular sink with proper async handling.
416
- * The returned sink chains async operations to ensure proper ordering and
417
- * implements AsyncDisposable to wait for all pending operations on disposal.
418
- *
419
- * @example Create a sink that asynchronously posts to a webhook
420
- * ```typescript
421
- * const asyncSink: AsyncSink = async (record) => {
422
- * await fetch("https://example.com/logs", {
423
- * method: "POST",
424
- * body: JSON.stringify(record),
425
- * });
426
- * };
427
- * const sink = fromAsyncSink(asyncSink);
428
- * ```
429
- *
430
- * @param asyncSink The async sink function to convert.
431
- * @returns A sink that properly handles async operations and disposal.
432
- * @since 1.0.0
433
- */
434
- export function fromAsyncSink(asyncSink: AsyncSink): Sink & AsyncDisposable {
435
- let lastPromise = Promise.resolve();
436
- const sink: Sink & AsyncDisposable = (record: LogRecord) => {
437
- lastPromise = lastPromise
438
- .then(() => asyncSink(record))
439
- .catch(() => {
440
- // Errors are handled by the sink infrastructure
441
- });
442
- };
443
- sink[Symbol.asyncDispose] = async () => {
444
- await lastPromise;
445
- };
446
- return sink;
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
- }