@logtape/logtape 1.1.0-dev.339 → 1.2.0-dev.341

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logtape/logtape",
3
- "version": "1.1.0-dev.339+bcd57298",
3
+ "version": "1.2.0-dev.341+a87baf94",
4
4
  "license": "MIT",
5
5
  "exports": "./src/mod.ts",
6
6
  "imports": {
package/dist/sink.cjs CHANGED
@@ -256,6 +256,7 @@ function fingersCrossed(sink, options = {}) {
256
256
  const triggerLevel = options.triggerLevel ?? "error";
257
257
  const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1e3);
258
258
  const isolateByCategory = options.isolateByCategory;
259
+ const isolateByContext = options.isolateByContext;
259
260
  try {
260
261
  require_level.compareLogLevel("trace", triggerLevel);
261
262
  } catch (error) {
@@ -290,7 +291,36 @@ function fingersCrossed(sink, options = {}) {
290
291
  function parseCategoryKey(key) {
291
292
  return JSON.parse(key);
292
293
  }
293
- if (!isolateByCategory) {
294
+ function getContextKey(properties) {
295
+ if (!isolateByContext || isolateByContext.keys.length === 0) return "";
296
+ const contextValues = {};
297
+ for (const key of isolateByContext.keys) if (key in properties) contextValues[key] = properties[key];
298
+ return JSON.stringify(contextValues);
299
+ }
300
+ function getBufferKey(category, properties) {
301
+ const categoryKey = getCategoryKey(category);
302
+ if (!isolateByContext) return categoryKey;
303
+ const contextKey = getContextKey(properties);
304
+ return `${categoryKey}:${contextKey}`;
305
+ }
306
+ function parseBufferKey(key) {
307
+ if (!isolateByContext) return {
308
+ category: parseCategoryKey(key),
309
+ context: ""
310
+ };
311
+ const separatorIndex = key.indexOf("]:");
312
+ if (separatorIndex === -1) return {
313
+ category: parseCategoryKey(key),
314
+ context: ""
315
+ };
316
+ const categoryPart = key.substring(0, separatorIndex + 1);
317
+ const contextPart = key.substring(separatorIndex + 2);
318
+ return {
319
+ category: parseCategoryKey(categoryPart),
320
+ context: contextPart
321
+ };
322
+ }
323
+ if (!isolateByCategory && !isolateByContext) {
294
324
  const buffer = [];
295
325
  let triggered = false;
296
326
  return (record) => {
@@ -312,19 +342,26 @@ function fingersCrossed(sink, options = {}) {
312
342
  const buffers = /* @__PURE__ */ new Map();
313
343
  const triggered = /* @__PURE__ */ new Set();
314
344
  return (record) => {
315
- const categoryKey = getCategoryKey(record.category);
316
- if (triggered.has(categoryKey)) {
345
+ const bufferKey = getBufferKey(record.category, record.properties);
346
+ if (triggered.has(bufferKey)) {
317
347
  sink(record);
318
348
  return;
319
349
  }
320
350
  if (require_level.compareLogLevel(record.level, triggerLevel) >= 0) {
321
351
  const keysToFlush = /* @__PURE__ */ new Set();
322
- for (const [bufferedKey] of buffers) if (bufferedKey === categoryKey) keysToFlush.add(bufferedKey);
323
- else if (shouldFlushBuffer) {
324
- const bufferedCategory = parseCategoryKey(bufferedKey);
325
- try {
326
- if (shouldFlushBuffer(record.category, bufferedCategory)) keysToFlush.add(bufferedKey);
352
+ for (const [bufferedKey] of buffers) if (bufferedKey === bufferKey) keysToFlush.add(bufferedKey);
353
+ else {
354
+ const { category: bufferedCategory, context: bufferedContext } = parseBufferKey(bufferedKey);
355
+ const { context: triggerContext } = parseBufferKey(bufferKey);
356
+ let contextMatches = true;
357
+ if (isolateByContext) contextMatches = bufferedContext === triggerContext;
358
+ let categoryMatches = false;
359
+ if (!isolateByCategory) categoryMatches = contextMatches;
360
+ else if (shouldFlushBuffer) try {
361
+ categoryMatches = shouldFlushBuffer(record.category, bufferedCategory);
327
362
  } catch {}
363
+ else categoryMatches = getCategoryKey(record.category) === getCategoryKey(bufferedCategory);
364
+ if (contextMatches && categoryMatches) keysToFlush.add(bufferedKey);
328
365
  }
329
366
  const allRecordsToFlush = [];
330
367
  for (const key of keysToFlush) {
@@ -337,13 +374,13 @@ function fingersCrossed(sink, options = {}) {
337
374
  }
338
375
  allRecordsToFlush.sort((a, b) => a.timestamp - b.timestamp);
339
376
  for (const bufferedRecord of allRecordsToFlush) sink(bufferedRecord);
340
- triggered.add(categoryKey);
377
+ triggered.add(bufferKey);
341
378
  sink(record);
342
379
  } else {
343
- let buffer = buffers.get(categoryKey);
380
+ let buffer = buffers.get(bufferKey);
344
381
  if (!buffer) {
345
382
  buffer = [];
346
- buffers.set(categoryKey, buffer);
383
+ buffers.set(bufferKey, buffer);
347
384
  }
348
385
  buffer.push(record);
349
386
  while (buffer.length > maxBufferSize) buffer.shift();
package/dist/sink.d.cts CHANGED
@@ -244,6 +244,37 @@ interface FingersCrossedOptions {
244
244
  * @default `undefined` (no isolation, single global buffer)
245
245
  */
246
246
  readonly isolateByCategory?: "descendant" | "ancestor" | "both" | ((triggerCategory: readonly string[], bufferedCategory: readonly string[]) => boolean);
247
+ /**
248
+ * Enable context-based buffer isolation.
249
+ * When enabled, buffers are isolated based on specified context keys.
250
+ * This is useful for scenarios like HTTP request tracing where logs
251
+ * should be isolated per request.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * fingersCrossed(sink, {
256
+ * isolateByContext: { keys: ['requestId'] }
257
+ * })
258
+ * ```
259
+ *
260
+ * @example Combined with category isolation
261
+ * ```typescript
262
+ * fingersCrossed(sink, {
263
+ * isolateByCategory: 'descendant',
264
+ * isolateByContext: { keys: ['requestId', 'sessionId'] }
265
+ * })
266
+ * ```
267
+ *
268
+ * @default `undefined` (no context isolation)
269
+ * @since 1.2.0
270
+ */
271
+ readonly isolateByContext?: {
272
+ /**
273
+ * Context keys to use for isolation.
274
+ * Buffers will be separate for different combinations of these context values.
275
+ */
276
+ readonly keys: readonly string[];
277
+ };
247
278
  }
248
279
  /**
249
280
  * Creates a sink that buffers log records until a trigger level is reached.
@@ -1 +1 @@
1
- {"version":3,"file":"sink.d.cts","names":[],"sources":["../src/sink.ts"],"sourcesContent":[],"mappings":";;;;;;;;;AAmBA;AAWA;;;;AAAsD;AAgBtD;AAA0B,KA3Bd,IAAA,GA2Bc,CAAA,MAAA,EA3BE,SA2BF,EAAA,GAAA,IAAA;;;;AAAsC;AAUhE;;;;AAS8C;AA+D9B,KAlGJ,SAAA,GAkGiB,CAAA,MAAA,EAlGI,SAkGJ,EAAA,GAlGkB,OAkGlB,CAAA,IAAA,CAAA;;;;;;AAGJ;AAgGxB;AAOD;;;;;;;AA2BY,iBAvNI,UAAA,CAuNJ,IAAA,EAvNqB,IAuNrB,EAAA,MAAA,EAvNmC,UAuNnC,CAAA,EAvNgD,IAuNhD;AAAO;AA8CnB;;AACW,UA5PM,iBAAA,CA4PN;EAAuB;;;EACN,SAAA,CAAA,EAzPd,aAyPc;EA4HZ;;;EAAkC,OAAG,CAAA,EAAA;IAAO,MAAA,CAAA,IAAA,EAAA,MAAA,CAAA,EAhXxB,UAgXwB;EAAe,CAAA;EAmB1D;AA8EjB;;;;;AAGO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBArZS,aAAA,SACN,0BACC,oBACR,OAAO;KAkGL,aAAA;;;;UAKY,kBAAA;;;;;cAKH,mBAAmB;;;;;;;;;;;;;;;;aAiBpB,OAAO,UAAU;;;;YAKlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8CI,cAAA,WACL,qBACR,QAAQ,OAAO;;;;;;;;;;;;;;;;;;;;;iBA4HF,aAAA,YAAyB,YAAY,OAAO;;;;;UAmB3C,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuEV,cAAA,OACR,gBACG,wBACR"}
1
+ {"version":3,"file":"sink.d.cts","names":[],"sources":["../src/sink.ts"],"sourcesContent":[],"mappings":";;;;;;;;;AAmBA;AAWA;;;;AAAsD;AAgBtD;AAA0B,KA3Bd,IAAA,GA2Bc,CAAA,MAAA,EA3BE,SA2BF,EAAA,GAAA,IAAA;;;;AAAsC;AAUhE;;;;AAS8C;AA+D9B,KAlGJ,SAAA,GAkGiB,CAAA,MAAA,EAlGI,SAkGJ,EAAA,GAlGkB,OAkGlB,CAAA,IAAA,CAAA;;;;;;AAGJ;AAgGxB;AAOD;;;;;;;AA2BY,iBAvNI,UAAA,CAuNJ,IAAA,EAvNqB,IAuNrB,EAAA,MAAA,EAvNmC,UAuNnC,CAAA,EAvNgD,IAuNhD;AAAO;AA8CnB;;AACW,UA5PM,iBAAA,CA4PN;EAAuB;;;EACN,SAAA,CAAA,EAzPd,aAyPc;EA4HZ;;;EAAkC,OAAG,CAAA,EAAA;IAAO,MAAA,CAAA,IAAA,EAAA,MAAA,CAAA,EAhXxB,UAgXwB;EAAe,CAAA;EAmB1D;AA8GjB;;;;;AAGO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBArbS,aAAA,SACN,0BACC,oBACR,OAAO;KAkGL,aAAA;;;;UAKY,kBAAA;;;;;cAKH,mBAAmB;;;;;;;;;;;;;;;;aAiBpB,OAAO,UAAU;;;;YAKlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8CI,cAAA,WACL,qBACR,QAAQ,OAAO;;;;;;;;;;;;;;;;;;;;;iBA4HF,aAAA,YAAyB,YAAY,OAAO;;;;;UAmB3C,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuGV,cAAA,OACR,gBACG,wBACR"}
package/dist/sink.d.ts CHANGED
@@ -244,6 +244,37 @@ interface FingersCrossedOptions {
244
244
  * @default `undefined` (no isolation, single global buffer)
245
245
  */
246
246
  readonly isolateByCategory?: "descendant" | "ancestor" | "both" | ((triggerCategory: readonly string[], bufferedCategory: readonly string[]) => boolean);
247
+ /**
248
+ * Enable context-based buffer isolation.
249
+ * When enabled, buffers are isolated based on specified context keys.
250
+ * This is useful for scenarios like HTTP request tracing where logs
251
+ * should be isolated per request.
252
+ *
253
+ * @example
254
+ * ```typescript
255
+ * fingersCrossed(sink, {
256
+ * isolateByContext: { keys: ['requestId'] }
257
+ * })
258
+ * ```
259
+ *
260
+ * @example Combined with category isolation
261
+ * ```typescript
262
+ * fingersCrossed(sink, {
263
+ * isolateByCategory: 'descendant',
264
+ * isolateByContext: { keys: ['requestId', 'sessionId'] }
265
+ * })
266
+ * ```
267
+ *
268
+ * @default `undefined` (no context isolation)
269
+ * @since 1.2.0
270
+ */
271
+ readonly isolateByContext?: {
272
+ /**
273
+ * Context keys to use for isolation.
274
+ * Buffers will be separate for different combinations of these context values.
275
+ */
276
+ readonly keys: readonly string[];
277
+ };
247
278
  }
248
279
  /**
249
280
  * Creates a sink that buffers log records until a trigger level is reached.
@@ -1 +1 @@
1
- {"version":3,"file":"sink.d.ts","names":[],"sources":["../src/sink.ts"],"sourcesContent":[],"mappings":";;;;;;;;;AAmBA;AAWA;;;;AAAsD;AAgBtD;AAA0B,KA3Bd,IAAA,GA2Bc,CAAA,MAAA,EA3BE,SA2BF,EAAA,GAAA,IAAA;;;;AAAsC;AAUhE;;;;AAS8C;AA+D9B,KAlGJ,SAAA,GAkGiB,CAAA,MAAA,EAlGI,SAkGJ,EAAA,GAlGkB,OAkGlB,CAAA,IAAA,CAAA;;;;;;AAGJ;AAgGxB;AAOD;;;;;;;AA2BY,iBAvNI,UAAA,CAuNJ,IAAA,EAvNqB,IAuNrB,EAAA,MAAA,EAvNmC,UAuNnC,CAAA,EAvNgD,IAuNhD;AAAO;AA8CnB;;AACW,UA5PM,iBAAA,CA4PN;EAAuB;;;EACN,SAAA,CAAA,EAzPd,aAyPc;EA4HZ;;;EAAkC,OAAG,CAAA,EAAA;IAAO,MAAA,CAAA,IAAA,EAAA,MAAA,CAAA,EAhXxB,UAgXwB;EAAe,CAAA;EAmB1D;AA8EjB;;;;;AAGO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBArZS,aAAA,SACN,0BACC,oBACR,OAAO;KAkGL,aAAA;;;;UAKY,kBAAA;;;;;cAKH,mBAAmB;;;;;;;;;;;;;;;;aAiBpB,OAAO,UAAU;;;;YAKlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8CI,cAAA,WACL,qBACR,QAAQ,OAAO;;;;;;;;;;;;;;;;;;;;;iBA4HF,aAAA,YAAyB,YAAY,OAAO;;;;;UAmB3C,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuEV,cAAA,OACR,gBACG,wBACR"}
1
+ {"version":3,"file":"sink.d.ts","names":[],"sources":["../src/sink.ts"],"sourcesContent":[],"mappings":";;;;;;;;;AAmBA;AAWA;;;;AAAsD;AAgBtD;AAA0B,KA3Bd,IAAA,GA2Bc,CAAA,MAAA,EA3BE,SA2BF,EAAA,GAAA,IAAA;;;;AAAsC;AAUhE;;;;AAS8C;AA+D9B,KAlGJ,SAAA,GAkGiB,CAAA,MAAA,EAlGI,SAkGJ,EAAA,GAlGkB,OAkGlB,CAAA,IAAA,CAAA;;;;;;AAGJ;AAgGxB;AAOD;;;;;;;AA2BY,iBAvNI,UAAA,CAuNJ,IAAA,EAvNqB,IAuNrB,EAAA,MAAA,EAvNmC,UAuNnC,CAAA,EAvNgD,IAuNhD;AAAO;AA8CnB;;AACW,UA5PM,iBAAA,CA4PN;EAAuB;;;EACN,SAAA,CAAA,EAzPd,aAyPc;EA4HZ;;;EAAkC,OAAG,CAAA,EAAA;IAAO,MAAA,CAAA,IAAA,EAAA,MAAA,CAAA,EAhXxB,UAgXwB;EAAe,CAAA;EAmB1D;AA8GjB;;;;;AAGO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBArbS,aAAA,SACN,0BACC,oBACR,OAAO;KAkGL,aAAA;;;;UAKY,kBAAA;;;;;cAKH,mBAAmB;;;;;;;;;;;;;;;;aAiBpB,OAAO,UAAU;;;;YAKlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA8CI,cAAA,WACL,qBACR,QAAQ,OAAO;;;;;;;;;;;;;;;;;;;;;iBA4HF,aAAA,YAAyB,YAAY,OAAO;;;;;UAmB3C,qBAAA;;;;;;;0BAOS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAuGV,cAAA,OACR,gBACG,wBACR"}
package/dist/sink.js CHANGED
@@ -256,6 +256,7 @@ function fingersCrossed(sink, options = {}) {
256
256
  const triggerLevel = options.triggerLevel ?? "error";
257
257
  const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1e3);
258
258
  const isolateByCategory = options.isolateByCategory;
259
+ const isolateByContext = options.isolateByContext;
259
260
  try {
260
261
  compareLogLevel("trace", triggerLevel);
261
262
  } catch (error) {
@@ -290,7 +291,36 @@ function fingersCrossed(sink, options = {}) {
290
291
  function parseCategoryKey(key) {
291
292
  return JSON.parse(key);
292
293
  }
293
- if (!isolateByCategory) {
294
+ function getContextKey(properties) {
295
+ if (!isolateByContext || isolateByContext.keys.length === 0) return "";
296
+ const contextValues = {};
297
+ for (const key of isolateByContext.keys) if (key in properties) contextValues[key] = properties[key];
298
+ return JSON.stringify(contextValues);
299
+ }
300
+ function getBufferKey(category, properties) {
301
+ const categoryKey = getCategoryKey(category);
302
+ if (!isolateByContext) return categoryKey;
303
+ const contextKey = getContextKey(properties);
304
+ return `${categoryKey}:${contextKey}`;
305
+ }
306
+ function parseBufferKey(key) {
307
+ if (!isolateByContext) return {
308
+ category: parseCategoryKey(key),
309
+ context: ""
310
+ };
311
+ const separatorIndex = key.indexOf("]:");
312
+ if (separatorIndex === -1) return {
313
+ category: parseCategoryKey(key),
314
+ context: ""
315
+ };
316
+ const categoryPart = key.substring(0, separatorIndex + 1);
317
+ const contextPart = key.substring(separatorIndex + 2);
318
+ return {
319
+ category: parseCategoryKey(categoryPart),
320
+ context: contextPart
321
+ };
322
+ }
323
+ if (!isolateByCategory && !isolateByContext) {
294
324
  const buffer = [];
295
325
  let triggered = false;
296
326
  return (record) => {
@@ -312,19 +342,26 @@ function fingersCrossed(sink, options = {}) {
312
342
  const buffers = /* @__PURE__ */ new Map();
313
343
  const triggered = /* @__PURE__ */ new Set();
314
344
  return (record) => {
315
- const categoryKey = getCategoryKey(record.category);
316
- if (triggered.has(categoryKey)) {
345
+ const bufferKey = getBufferKey(record.category, record.properties);
346
+ if (triggered.has(bufferKey)) {
317
347
  sink(record);
318
348
  return;
319
349
  }
320
350
  if (compareLogLevel(record.level, triggerLevel) >= 0) {
321
351
  const keysToFlush = /* @__PURE__ */ new Set();
322
- for (const [bufferedKey] of buffers) if (bufferedKey === categoryKey) keysToFlush.add(bufferedKey);
323
- else if (shouldFlushBuffer) {
324
- const bufferedCategory = parseCategoryKey(bufferedKey);
325
- try {
326
- if (shouldFlushBuffer(record.category, bufferedCategory)) keysToFlush.add(bufferedKey);
352
+ for (const [bufferedKey] of buffers) if (bufferedKey === bufferKey) keysToFlush.add(bufferedKey);
353
+ else {
354
+ const { category: bufferedCategory, context: bufferedContext } = parseBufferKey(bufferedKey);
355
+ const { context: triggerContext } = parseBufferKey(bufferKey);
356
+ let contextMatches = true;
357
+ if (isolateByContext) contextMatches = bufferedContext === triggerContext;
358
+ let categoryMatches = false;
359
+ if (!isolateByCategory) categoryMatches = contextMatches;
360
+ else if (shouldFlushBuffer) try {
361
+ categoryMatches = shouldFlushBuffer(record.category, bufferedCategory);
327
362
  } catch {}
363
+ else categoryMatches = getCategoryKey(record.category) === getCategoryKey(bufferedCategory);
364
+ if (contextMatches && categoryMatches) keysToFlush.add(bufferedKey);
328
365
  }
329
366
  const allRecordsToFlush = [];
330
367
  for (const key of keysToFlush) {
@@ -337,13 +374,13 @@ function fingersCrossed(sink, options = {}) {
337
374
  }
338
375
  allRecordsToFlush.sort((a, b) => a.timestamp - b.timestamp);
339
376
  for (const bufferedRecord of allRecordsToFlush) sink(bufferedRecord);
340
- triggered.add(categoryKey);
377
+ triggered.add(bufferKey);
341
378
  sink(record);
342
379
  } else {
343
- let buffer = buffers.get(categoryKey);
380
+ let buffer = buffers.get(bufferKey);
344
381
  if (!buffer) {
345
382
  buffer = [];
346
- buffers.set(categoryKey, buffer);
383
+ buffers.set(bufferKey, buffer);
347
384
  }
348
385
  buffer.push(record);
349
386
  while (buffer.length > maxBufferSize) buffer.shift();
package/dist/sink.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"sink.js","names":["sink: Sink","filter: FilterLike","record: LogRecord","stream: WritableStream","options: StreamSinkOptions","sink: Sink & AsyncDisposable","buffer: LogRecord[]","flushTimer: ReturnType<typeof setInterval> | null","activeFlush: Promise<void> | null","nonBlockingSink: Sink & AsyncDisposable","options: ConsoleSinkOptions","levelMap: Record<LogLevel, ConsoleMethod>","nonBlockingSink: Sink & Disposable","asyncSink: AsyncSink","options: FingersCrossedOptions","parent: readonly string[]","child: readonly string[]","shouldFlushBuffer:\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean)\n | null","category: readonly string[]","key: string","allRecordsToFlush: LogRecord[]"],"sources":["../src/sink.ts"],"sourcesContent":["import { type FilterLike, toFilter } from \"./filter.ts\";\nimport {\n type ConsoleFormatter,\n defaultConsoleFormatter,\n defaultTextFormatter,\n type TextFormatter,\n} from \"./formatter.ts\";\nimport { compareLogLevel, type LogLevel } from \"./level.ts\";\nimport type { LogRecord } from \"./record.ts\";\n\n/**\n * A sink is a function that accepts a log record and prints it somewhere.\n * Thrown exceptions will be suppressed and then logged to the meta logger,\n * a {@link Logger} with the category `[\"logtape\", \"meta\"]`. (In that case,\n * the meta log record will not be passed to the sink to avoid infinite\n * recursion.)\n *\n * @param record The log record to sink.\n */\nexport type Sink = (record: LogRecord) => void;\n\n/**\n * An async sink is a function that accepts a log record and asynchronously\n * processes it. This type is used with {@link fromAsyncSink} to create\n * a regular sink that properly handles asynchronous operations.\n *\n * @param record The log record to process asynchronously.\n * @returns A promise that resolves when the record has been processed.\n * @since 1.0.0\n */\nexport type AsyncSink = (record: LogRecord) => Promise<void>;\n\n/**\n * Turns a sink into a filtered sink. The returned sink only logs records that\n * pass the filter.\n *\n * @example Filter a console sink to only log records with the info level\n * ```typescript\n * const sink = withFilter(getConsoleSink(), \"info\");\n * ```\n *\n * @param sink A sink to be filtered.\n * @param filter A filter to apply to the sink. It can be either a filter\n * function or a {@link LogLevel} string.\n * @returns A sink that only logs records that pass the filter.\n */\nexport function withFilter(sink: Sink, filter: FilterLike): Sink {\n const filterFunc = toFilter(filter);\n return (record: LogRecord) => {\n if (filterFunc(record)) sink(record);\n };\n}\n\n/**\n * Options for the {@link getStreamSink} function.\n */\nexport interface StreamSinkOptions {\n /**\n * The text formatter to use. Defaults to {@link defaultTextFormatter}.\n */\n formatter?: TextFormatter;\n\n /**\n * The text encoder to use. Defaults to an instance of {@link TextEncoder}.\n */\n encoder?: { encode(text: string): Uint8Array };\n\n /**\n * Enable non-blocking mode with optional buffer configuration.\n * When enabled, log records are buffered and flushed in the background.\n *\n * @example Simple non-blocking mode\n * ```typescript\n * getStreamSink(stream, { nonBlocking: true });\n * ```\n *\n * @example Custom buffer configuration\n * ```typescript\n * getStreamSink(stream, {\n * nonBlocking: {\n * bufferSize: 1000,\n * flushInterval: 50\n * }\n * });\n * ```\n *\n * @default `false`\n * @since 1.0.0\n */\n nonBlocking?: boolean | {\n /**\n * Maximum number of records to buffer before flushing.\n * @default `100`\n */\n bufferSize?: number;\n\n /**\n * Interval in milliseconds between automatic flushes.\n * @default `100`\n */\n flushInterval?: number;\n };\n}\n\n/**\n * A factory that returns a sink that writes to a {@link WritableStream}.\n *\n * Note that the `stream` is of Web Streams API, which is different from\n * Node.js streams. You can convert a Node.js stream to a Web Streams API\n * stream using [`stream.Writable.toWeb()`] method.\n *\n * [`stream.Writable.toWeb()`]: https://nodejs.org/api/stream.html#streamwritabletowebstreamwritable\n *\n * @example Sink to the standard error in Deno\n * ```typescript\n * const stderrSink = getStreamSink(Deno.stderr.writable);\n * ```\n *\n * @example Sink to the standard error in Node.js\n * ```typescript\n * import stream from \"node:stream\";\n * const stderrSink = getStreamSink(stream.Writable.toWeb(process.stderr));\n * ```\n *\n * @param stream The stream to write to.\n * @param options The options for the sink.\n * @returns A sink that writes to the stream.\n */\nexport function getStreamSink(\n stream: WritableStream,\n options: StreamSinkOptions = {},\n): Sink & AsyncDisposable {\n const formatter = options.formatter ?? defaultTextFormatter;\n const encoder = options.encoder ?? new TextEncoder();\n const writer = stream.getWriter();\n\n if (!options.nonBlocking) {\n let lastPromise = Promise.resolve();\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n const bytes = encoder.encode(formatter(record));\n lastPromise = lastPromise\n .then(() => writer.ready)\n .then(() => writer.write(bytes));\n };\n sink[Symbol.asyncDispose] = async () => {\n await lastPromise;\n await writer.close();\n };\n return sink;\n }\n\n // Non-blocking mode implementation\n const nonBlockingConfig = options.nonBlocking === true\n ? {}\n : options.nonBlocking;\n const bufferSize = nonBlockingConfig.bufferSize ?? 100;\n const flushInterval = nonBlockingConfig.flushInterval ?? 100;\n\n const buffer: LogRecord[] = [];\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let disposed = false;\n let activeFlush: Promise<void> | null = null;\n const maxBufferSize = bufferSize * 2; // Overflow protection\n\n async function flush(): Promise<void> {\n if (buffer.length === 0) return;\n\n const records = buffer.splice(0);\n for (const record of records) {\n try {\n const bytes = encoder.encode(formatter(record));\n await writer.ready;\n await writer.write(bytes);\n } catch {\n // Silently ignore errors in non-blocking mode to avoid disrupting the application\n }\n }\n }\n\n function scheduleFlush(): void {\n if (activeFlush) return;\n\n activeFlush = flush().finally(() => {\n activeFlush = null;\n });\n }\n\n function startFlushTimer(): void {\n if (flushTimer !== null || disposed) return;\n\n flushTimer = setInterval(() => {\n scheduleFlush();\n }, flushInterval);\n }\n\n const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Buffer overflow protection: drop oldest records if buffer is too large\n if (buffer.length >= maxBufferSize) {\n buffer.shift(); // Remove oldest record\n }\n\n buffer.push(record);\n\n if (buffer.length >= bufferSize) {\n scheduleFlush();\n } else if (flushTimer === null) {\n startFlushTimer();\n }\n };\n\n nonBlockingSink[Symbol.asyncDispose] = async () => {\n disposed = true;\n if (flushTimer !== null) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n await flush();\n try {\n await writer.close();\n } catch {\n // Writer might already be closed or errored\n }\n };\n\n return nonBlockingSink;\n}\n\ntype ConsoleMethod = \"debug\" | \"info\" | \"log\" | \"warn\" | \"error\";\n\n/**\n * Options for the {@link getConsoleSink} function.\n */\nexport interface ConsoleSinkOptions {\n /**\n * The console formatter or text formatter to use.\n * Defaults to {@link defaultConsoleFormatter}.\n */\n formatter?: ConsoleFormatter | TextFormatter;\n\n /**\n * The mapping from log levels to console methods. Defaults to:\n *\n * ```typescript\n * {\n * trace: \"trace\",\n * debug: \"debug\",\n * info: \"info\",\n * warning: \"warn\",\n * error: \"error\",\n * fatal: \"error\",\n * }\n * ```\n * @since 0.9.0\n */\n levelMap?: Record<LogLevel, ConsoleMethod>;\n\n /**\n * The console to log to. Defaults to {@link console}.\n */\n console?: Console;\n\n /**\n * Enable non-blocking mode with optional buffer configuration.\n * When enabled, log records are buffered and flushed in the background.\n *\n * @example Simple non-blocking mode\n * ```typescript\n * getConsoleSink({ nonBlocking: true });\n * ```\n *\n * @example Custom buffer configuration\n * ```typescript\n * getConsoleSink({\n * nonBlocking: {\n * bufferSize: 1000,\n * flushInterval: 50\n * }\n * });\n * ```\n *\n * @default `false`\n * @since 1.0.0\n */\n nonBlocking?: boolean | {\n /**\n * Maximum number of records to buffer before flushing.\n * @default `100`\n */\n bufferSize?: number;\n\n /**\n * Interval in milliseconds between automatic flushes.\n * @default `100`\n */\n flushInterval?: number;\n };\n}\n\n/**\n * A console sink factory that returns a sink that logs to the console.\n *\n * @param options The options for the sink.\n * @returns A sink that logs to the console. If `nonBlocking` is enabled,\n * returns a sink that also implements {@link Disposable}.\n */\nexport function getConsoleSink(\n options: ConsoleSinkOptions = {},\n): Sink | (Sink & Disposable) {\n const formatter = options.formatter ?? defaultConsoleFormatter;\n const levelMap: Record<LogLevel, ConsoleMethod> = {\n trace: \"debug\",\n debug: \"debug\",\n info: \"info\",\n warning: \"warn\",\n error: \"error\",\n fatal: \"error\",\n ...(options.levelMap ?? {}),\n };\n const console = options.console ?? globalThis.console;\n\n const baseSink = (record: LogRecord) => {\n const args = formatter(record);\n const method = levelMap[record.level];\n if (method === undefined) {\n throw new TypeError(`Invalid log level: ${record.level}.`);\n }\n if (typeof args === \"string\") {\n const msg = args.replace(/\\r?\\n$/, \"\");\n console[method](msg);\n } else {\n console[method](...args);\n }\n };\n\n if (!options.nonBlocking) {\n return baseSink;\n }\n\n // Non-blocking mode implementation\n const nonBlockingConfig = options.nonBlocking === true\n ? {}\n : options.nonBlocking;\n const bufferSize = nonBlockingConfig.bufferSize ?? 100;\n const flushInterval = nonBlockingConfig.flushInterval ?? 100;\n\n const buffer: LogRecord[] = [];\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let disposed = false;\n let flushScheduled = false;\n const maxBufferSize = bufferSize * 2; // Overflow protection\n\n function flush(): void {\n if (buffer.length === 0) return;\n\n const records = buffer.splice(0);\n for (const record of records) {\n try {\n baseSink(record);\n } catch {\n // Silently ignore errors in non-blocking mode to avoid disrupting the application\n }\n }\n }\n\n function scheduleFlush(): void {\n if (flushScheduled) return;\n\n flushScheduled = true;\n setTimeout(() => {\n flushScheduled = false;\n flush();\n }, 0);\n }\n\n function startFlushTimer(): void {\n if (flushTimer !== null || disposed) return;\n\n flushTimer = setInterval(() => {\n flush();\n }, flushInterval);\n }\n\n const nonBlockingSink: Sink & Disposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Buffer overflow protection: drop oldest records if buffer is too large\n if (buffer.length >= maxBufferSize) {\n buffer.shift(); // Remove oldest record\n }\n\n buffer.push(record);\n\n if (buffer.length >= bufferSize) {\n scheduleFlush();\n } else if (flushTimer === null) {\n startFlushTimer();\n }\n };\n\n nonBlockingSink[Symbol.dispose] = () => {\n disposed = true;\n if (flushTimer !== null) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n flush();\n };\n\n return nonBlockingSink;\n}\n\n/**\n * Converts an async sink into a regular sink with proper async handling.\n * The returned sink chains async operations to ensure proper ordering and\n * implements AsyncDisposable to wait for all pending operations on disposal.\n *\n * @example Create a sink that asynchronously posts to a webhook\n * ```typescript\n * const asyncSink: AsyncSink = async (record) => {\n * await fetch(\"https://example.com/logs\", {\n * method: \"POST\",\n * body: JSON.stringify(record),\n * });\n * };\n * const sink = fromAsyncSink(asyncSink);\n * ```\n *\n * @param asyncSink The async sink function to convert.\n * @returns A sink that properly handles async operations and disposal.\n * @since 1.0.0\n */\nexport function fromAsyncSink(asyncSink: AsyncSink): Sink & AsyncDisposable {\n let lastPromise = Promise.resolve();\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n lastPromise = lastPromise\n .then(() => asyncSink(record))\n .catch(() => {\n // Errors are handled by the sink infrastructure\n });\n };\n sink[Symbol.asyncDispose] = async () => {\n await lastPromise;\n };\n return sink;\n}\n\n/**\n * Options for the {@link fingersCrossed} function.\n * @since 1.1.0\n */\nexport interface FingersCrossedOptions {\n /**\n * Minimum log level that triggers buffer flush.\n * When a log record at or above this level is received, all buffered\n * records are flushed to the wrapped sink.\n * @default `\"error\"`\n */\n readonly triggerLevel?: LogLevel;\n\n /**\n * Maximum buffer size before oldest records are dropped.\n * When the buffer exceeds this size, the oldest records are removed\n * to prevent unbounded memory growth.\n * @default `1000`\n */\n readonly maxBufferSize?: number;\n\n /**\n * Category isolation mode or custom matcher function.\n *\n * When `undefined` (default), all log records share a single buffer.\n *\n * When set to a mode string:\n *\n * - `\"descendant\"`: Flush child category buffers when parent triggers\n * - `\"ancestor\"`: Flush parent category buffers when child triggers\n * - `\"both\"`: Flush both parent and child category buffers\n *\n * When set to a function, it receives the trigger category and buffered\n * category and should return true if the buffered category should be flushed.\n *\n * @default `undefined` (no isolation, single global buffer)\n */\n readonly isolateByCategory?:\n | \"descendant\"\n | \"ancestor\"\n | \"both\"\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean);\n}\n\n/**\n * Creates a sink that buffers log records until a trigger level is reached.\n * This pattern, known as \"fingers crossed\" logging, keeps detailed debug logs\n * in memory and only outputs them when an error or other significant event occurs.\n *\n * @example Basic usage with default settings\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink());\n * // Debug and info logs are buffered\n * // When an error occurs, all buffered logs + the error are output\n * ```\n *\n * @example Custom trigger level and buffer size\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink(), {\n * triggerLevel: \"warning\", // Trigger on warning or higher\n * maxBufferSize: 500 // Keep last 500 records\n * });\n * ```\n *\n * @example Category isolation\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink(), {\n * isolateByCategory: \"descendant\" // Separate buffers per category\n * });\n * // Error in [\"app\"] triggers flush of [\"app\"] and [\"app\", \"module\"] buffers\n * // But not [\"other\"] buffer\n * ```\n *\n * @param sink The sink to wrap. Buffered records are sent to this sink when\n * triggered.\n * @param options Configuration options for the fingers crossed behavior.\n * @returns A sink that buffers records until the trigger level is reached.\n * @since 1.1.0\n */\nexport function fingersCrossed(\n sink: Sink,\n options: FingersCrossedOptions = {},\n): Sink {\n const triggerLevel = options.triggerLevel ?? \"error\";\n const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1000);\n const isolateByCategory = options.isolateByCategory;\n\n // Validate trigger level early\n try {\n compareLogLevel(\"trace\", triggerLevel); // Test with any valid level\n } catch (error) {\n throw new TypeError(\n `Invalid triggerLevel: ${JSON.stringify(triggerLevel)}. ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n\n // Helper functions for category matching\n function isDescendant(\n parent: readonly string[],\n child: readonly string[],\n ): boolean {\n if (parent.length === 0 || child.length === 0) return false; // Empty categories are isolated\n if (parent.length > child.length) return false;\n return parent.every((p, i) => p === child[i]);\n }\n\n function isAncestor(\n child: readonly string[],\n parent: readonly string[],\n ): boolean {\n if (child.length === 0 || parent.length === 0) return false; // Empty categories are isolated\n if (child.length < parent.length) return false;\n return parent.every((p, i) => p === child[i]);\n }\n\n // Determine matcher function based on isolation mode\n let shouldFlushBuffer:\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean)\n | null = null;\n\n if (isolateByCategory) {\n if (typeof isolateByCategory === \"function\") {\n shouldFlushBuffer = isolateByCategory;\n } else {\n switch (isolateByCategory) {\n case \"descendant\":\n shouldFlushBuffer = (trigger, buffered) =>\n isDescendant(trigger, buffered);\n break;\n case \"ancestor\":\n shouldFlushBuffer = (trigger, buffered) =>\n isAncestor(trigger, buffered);\n break;\n case \"both\":\n shouldFlushBuffer = (trigger, buffered) =>\n isDescendant(trigger, buffered) || isAncestor(trigger, buffered);\n break;\n }\n }\n }\n\n // Helper functions for category serialization\n function getCategoryKey(category: readonly string[]): string {\n return JSON.stringify(category);\n }\n\n function parseCategoryKey(key: string): string[] {\n return JSON.parse(key);\n }\n\n // Buffer management\n if (!isolateByCategory) {\n // Single global buffer\n const buffer: LogRecord[] = [];\n let triggered = false;\n\n return (record: LogRecord) => {\n if (triggered) {\n // Already triggered, pass through directly\n sink(record);\n return;\n }\n\n // Check if this record triggers flush\n if (compareLogLevel(record.level, triggerLevel) >= 0) {\n triggered = true;\n\n // Flush buffer\n for (const bufferedRecord of buffer) {\n sink(bufferedRecord);\n }\n buffer.length = 0;\n\n // Send trigger record\n sink(record);\n } else {\n // Buffer the record\n buffer.push(record);\n\n // Enforce max buffer size\n while (buffer.length > maxBufferSize) {\n buffer.shift();\n }\n }\n };\n } else {\n // Category-isolated buffers\n const buffers = new Map<string, LogRecord[]>();\n const triggered = new Set<string>();\n\n return (record: LogRecord) => {\n const categoryKey = getCategoryKey(record.category);\n\n // Check if this category is already triggered\n if (triggered.has(categoryKey)) {\n sink(record);\n return;\n }\n\n // Check if this record triggers flush\n if (compareLogLevel(record.level, triggerLevel) >= 0) {\n // Find all buffers that should be flushed\n const keysToFlush = new Set<string>();\n\n for (const [bufferedKey] of buffers) {\n if (bufferedKey === categoryKey) {\n keysToFlush.add(bufferedKey);\n } else if (shouldFlushBuffer) {\n const bufferedCategory = parseCategoryKey(bufferedKey);\n try {\n if (shouldFlushBuffer(record.category, bufferedCategory)) {\n keysToFlush.add(bufferedKey);\n }\n } catch {\n // Ignore errors from custom matcher\n }\n }\n }\n\n // Flush matching buffers\n const allRecordsToFlush: LogRecord[] = [];\n for (const key of keysToFlush) {\n const buffer = buffers.get(key);\n if (buffer) {\n allRecordsToFlush.push(...buffer);\n buffers.delete(key);\n triggered.add(key);\n }\n }\n\n // Sort by timestamp to maintain chronological order\n allRecordsToFlush.sort((a, b) => a.timestamp - b.timestamp);\n\n // Flush all records\n for (const bufferedRecord of allRecordsToFlush) {\n sink(bufferedRecord);\n }\n\n // Mark trigger category as triggered and send trigger record\n triggered.add(categoryKey);\n sink(record);\n } else {\n // Buffer the record\n let buffer = buffers.get(categoryKey);\n if (!buffer) {\n buffer = [];\n buffers.set(categoryKey, buffer);\n }\n\n buffer.push(record);\n\n // Enforce max buffer size per category\n while (buffer.length > maxBufferSize) {\n buffer.shift();\n }\n }\n };\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sink.js","names":["sink: Sink","filter: FilterLike","record: LogRecord","stream: WritableStream","options: StreamSinkOptions","sink: Sink & AsyncDisposable","buffer: LogRecord[]","flushTimer: ReturnType<typeof setInterval> | null","activeFlush: Promise<void> | null","nonBlockingSink: Sink & AsyncDisposable","options: ConsoleSinkOptions","levelMap: Record<LogLevel, ConsoleMethod>","nonBlockingSink: Sink & Disposable","asyncSink: AsyncSink","options: FingersCrossedOptions","parent: readonly string[]","child: readonly string[]","shouldFlushBuffer:\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean)\n | null","category: readonly string[]","key: string","properties: Record<string, unknown>","contextValues: Record<string, unknown>","allRecordsToFlush: LogRecord[]"],"sources":["../src/sink.ts"],"sourcesContent":["import { type FilterLike, toFilter } from \"./filter.ts\";\nimport {\n type ConsoleFormatter,\n defaultConsoleFormatter,\n defaultTextFormatter,\n type TextFormatter,\n} from \"./formatter.ts\";\nimport { compareLogLevel, type LogLevel } from \"./level.ts\";\nimport type { LogRecord } from \"./record.ts\";\n\n/**\n * A sink is a function that accepts a log record and prints it somewhere.\n * Thrown exceptions will be suppressed and then logged to the meta logger,\n * a {@link Logger} with the category `[\"logtape\", \"meta\"]`. (In that case,\n * the meta log record will not be passed to the sink to avoid infinite\n * recursion.)\n *\n * @param record The log record to sink.\n */\nexport type Sink = (record: LogRecord) => void;\n\n/**\n * An async sink is a function that accepts a log record and asynchronously\n * processes it. This type is used with {@link fromAsyncSink} to create\n * a regular sink that properly handles asynchronous operations.\n *\n * @param record The log record to process asynchronously.\n * @returns A promise that resolves when the record has been processed.\n * @since 1.0.0\n */\nexport type AsyncSink = (record: LogRecord) => Promise<void>;\n\n/**\n * Turns a sink into a filtered sink. The returned sink only logs records that\n * pass the filter.\n *\n * @example Filter a console sink to only log records with the info level\n * ```typescript\n * const sink = withFilter(getConsoleSink(), \"info\");\n * ```\n *\n * @param sink A sink to be filtered.\n * @param filter A filter to apply to the sink. It can be either a filter\n * function or a {@link LogLevel} string.\n * @returns A sink that only logs records that pass the filter.\n */\nexport function withFilter(sink: Sink, filter: FilterLike): Sink {\n const filterFunc = toFilter(filter);\n return (record: LogRecord) => {\n if (filterFunc(record)) sink(record);\n };\n}\n\n/**\n * Options for the {@link getStreamSink} function.\n */\nexport interface StreamSinkOptions {\n /**\n * The text formatter to use. Defaults to {@link defaultTextFormatter}.\n */\n formatter?: TextFormatter;\n\n /**\n * The text encoder to use. Defaults to an instance of {@link TextEncoder}.\n */\n encoder?: { encode(text: string): Uint8Array };\n\n /**\n * Enable non-blocking mode with optional buffer configuration.\n * When enabled, log records are buffered and flushed in the background.\n *\n * @example Simple non-blocking mode\n * ```typescript\n * getStreamSink(stream, { nonBlocking: true });\n * ```\n *\n * @example Custom buffer configuration\n * ```typescript\n * getStreamSink(stream, {\n * nonBlocking: {\n * bufferSize: 1000,\n * flushInterval: 50\n * }\n * });\n * ```\n *\n * @default `false`\n * @since 1.0.0\n */\n nonBlocking?: boolean | {\n /**\n * Maximum number of records to buffer before flushing.\n * @default `100`\n */\n bufferSize?: number;\n\n /**\n * Interval in milliseconds between automatic flushes.\n * @default `100`\n */\n flushInterval?: number;\n };\n}\n\n/**\n * A factory that returns a sink that writes to a {@link WritableStream}.\n *\n * Note that the `stream` is of Web Streams API, which is different from\n * Node.js streams. You can convert a Node.js stream to a Web Streams API\n * stream using [`stream.Writable.toWeb()`] method.\n *\n * [`stream.Writable.toWeb()`]: https://nodejs.org/api/stream.html#streamwritabletowebstreamwritable\n *\n * @example Sink to the standard error in Deno\n * ```typescript\n * const stderrSink = getStreamSink(Deno.stderr.writable);\n * ```\n *\n * @example Sink to the standard error in Node.js\n * ```typescript\n * import stream from \"node:stream\";\n * const stderrSink = getStreamSink(stream.Writable.toWeb(process.stderr));\n * ```\n *\n * @param stream The stream to write to.\n * @param options The options for the sink.\n * @returns A sink that writes to the stream.\n */\nexport function getStreamSink(\n stream: WritableStream,\n options: StreamSinkOptions = {},\n): Sink & AsyncDisposable {\n const formatter = options.formatter ?? defaultTextFormatter;\n const encoder = options.encoder ?? new TextEncoder();\n const writer = stream.getWriter();\n\n if (!options.nonBlocking) {\n let lastPromise = Promise.resolve();\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n const bytes = encoder.encode(formatter(record));\n lastPromise = lastPromise\n .then(() => writer.ready)\n .then(() => writer.write(bytes));\n };\n sink[Symbol.asyncDispose] = async () => {\n await lastPromise;\n await writer.close();\n };\n return sink;\n }\n\n // Non-blocking mode implementation\n const nonBlockingConfig = options.nonBlocking === true\n ? {}\n : options.nonBlocking;\n const bufferSize = nonBlockingConfig.bufferSize ?? 100;\n const flushInterval = nonBlockingConfig.flushInterval ?? 100;\n\n const buffer: LogRecord[] = [];\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let disposed = false;\n let activeFlush: Promise<void> | null = null;\n const maxBufferSize = bufferSize * 2; // Overflow protection\n\n async function flush(): Promise<void> {\n if (buffer.length === 0) return;\n\n const records = buffer.splice(0);\n for (const record of records) {\n try {\n const bytes = encoder.encode(formatter(record));\n await writer.ready;\n await writer.write(bytes);\n } catch {\n // Silently ignore errors in non-blocking mode to avoid disrupting the application\n }\n }\n }\n\n function scheduleFlush(): void {\n if (activeFlush) return;\n\n activeFlush = flush().finally(() => {\n activeFlush = null;\n });\n }\n\n function startFlushTimer(): void {\n if (flushTimer !== null || disposed) return;\n\n flushTimer = setInterval(() => {\n scheduleFlush();\n }, flushInterval);\n }\n\n const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Buffer overflow protection: drop oldest records if buffer is too large\n if (buffer.length >= maxBufferSize) {\n buffer.shift(); // Remove oldest record\n }\n\n buffer.push(record);\n\n if (buffer.length >= bufferSize) {\n scheduleFlush();\n } else if (flushTimer === null) {\n startFlushTimer();\n }\n };\n\n nonBlockingSink[Symbol.asyncDispose] = async () => {\n disposed = true;\n if (flushTimer !== null) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n await flush();\n try {\n await writer.close();\n } catch {\n // Writer might already be closed or errored\n }\n };\n\n return nonBlockingSink;\n}\n\ntype ConsoleMethod = \"debug\" | \"info\" | \"log\" | \"warn\" | \"error\";\n\n/**\n * Options for the {@link getConsoleSink} function.\n */\nexport interface ConsoleSinkOptions {\n /**\n * The console formatter or text formatter to use.\n * Defaults to {@link defaultConsoleFormatter}.\n */\n formatter?: ConsoleFormatter | TextFormatter;\n\n /**\n * The mapping from log levels to console methods. Defaults to:\n *\n * ```typescript\n * {\n * trace: \"trace\",\n * debug: \"debug\",\n * info: \"info\",\n * warning: \"warn\",\n * error: \"error\",\n * fatal: \"error\",\n * }\n * ```\n * @since 0.9.0\n */\n levelMap?: Record<LogLevel, ConsoleMethod>;\n\n /**\n * The console to log to. Defaults to {@link console}.\n */\n console?: Console;\n\n /**\n * Enable non-blocking mode with optional buffer configuration.\n * When enabled, log records are buffered and flushed in the background.\n *\n * @example Simple non-blocking mode\n * ```typescript\n * getConsoleSink({ nonBlocking: true });\n * ```\n *\n * @example Custom buffer configuration\n * ```typescript\n * getConsoleSink({\n * nonBlocking: {\n * bufferSize: 1000,\n * flushInterval: 50\n * }\n * });\n * ```\n *\n * @default `false`\n * @since 1.0.0\n */\n nonBlocking?: boolean | {\n /**\n * Maximum number of records to buffer before flushing.\n * @default `100`\n */\n bufferSize?: number;\n\n /**\n * Interval in milliseconds between automatic flushes.\n * @default `100`\n */\n flushInterval?: number;\n };\n}\n\n/**\n * A console sink factory that returns a sink that logs to the console.\n *\n * @param options The options for the sink.\n * @returns A sink that logs to the console. If `nonBlocking` is enabled,\n * returns a sink that also implements {@link Disposable}.\n */\nexport function getConsoleSink(\n options: ConsoleSinkOptions = {},\n): Sink | (Sink & Disposable) {\n const formatter = options.formatter ?? defaultConsoleFormatter;\n const levelMap: Record<LogLevel, ConsoleMethod> = {\n trace: \"debug\",\n debug: \"debug\",\n info: \"info\",\n warning: \"warn\",\n error: \"error\",\n fatal: \"error\",\n ...(options.levelMap ?? {}),\n };\n const console = options.console ?? globalThis.console;\n\n const baseSink = (record: LogRecord) => {\n const args = formatter(record);\n const method = levelMap[record.level];\n if (method === undefined) {\n throw new TypeError(`Invalid log level: ${record.level}.`);\n }\n if (typeof args === \"string\") {\n const msg = args.replace(/\\r?\\n$/, \"\");\n console[method](msg);\n } else {\n console[method](...args);\n }\n };\n\n if (!options.nonBlocking) {\n return baseSink;\n }\n\n // Non-blocking mode implementation\n const nonBlockingConfig = options.nonBlocking === true\n ? {}\n : options.nonBlocking;\n const bufferSize = nonBlockingConfig.bufferSize ?? 100;\n const flushInterval = nonBlockingConfig.flushInterval ?? 100;\n\n const buffer: LogRecord[] = [];\n let flushTimer: ReturnType<typeof setInterval> | null = null;\n let disposed = false;\n let flushScheduled = false;\n const maxBufferSize = bufferSize * 2; // Overflow protection\n\n function flush(): void {\n if (buffer.length === 0) return;\n\n const records = buffer.splice(0);\n for (const record of records) {\n try {\n baseSink(record);\n } catch {\n // Silently ignore errors in non-blocking mode to avoid disrupting the application\n }\n }\n }\n\n function scheduleFlush(): void {\n if (flushScheduled) return;\n\n flushScheduled = true;\n setTimeout(() => {\n flushScheduled = false;\n flush();\n }, 0);\n }\n\n function startFlushTimer(): void {\n if (flushTimer !== null || disposed) return;\n\n flushTimer = setInterval(() => {\n flush();\n }, flushInterval);\n }\n\n const nonBlockingSink: Sink & Disposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Buffer overflow protection: drop oldest records if buffer is too large\n if (buffer.length >= maxBufferSize) {\n buffer.shift(); // Remove oldest record\n }\n\n buffer.push(record);\n\n if (buffer.length >= bufferSize) {\n scheduleFlush();\n } else if (flushTimer === null) {\n startFlushTimer();\n }\n };\n\n nonBlockingSink[Symbol.dispose] = () => {\n disposed = true;\n if (flushTimer !== null) {\n clearInterval(flushTimer);\n flushTimer = null;\n }\n flush();\n };\n\n return nonBlockingSink;\n}\n\n/**\n * Converts an async sink into a regular sink with proper async handling.\n * The returned sink chains async operations to ensure proper ordering and\n * implements AsyncDisposable to wait for all pending operations on disposal.\n *\n * @example Create a sink that asynchronously posts to a webhook\n * ```typescript\n * const asyncSink: AsyncSink = async (record) => {\n * await fetch(\"https://example.com/logs\", {\n * method: \"POST\",\n * body: JSON.stringify(record),\n * });\n * };\n * const sink = fromAsyncSink(asyncSink);\n * ```\n *\n * @param asyncSink The async sink function to convert.\n * @returns A sink that properly handles async operations and disposal.\n * @since 1.0.0\n */\nexport function fromAsyncSink(asyncSink: AsyncSink): Sink & AsyncDisposable {\n let lastPromise = Promise.resolve();\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n lastPromise = lastPromise\n .then(() => asyncSink(record))\n .catch(() => {\n // Errors are handled by the sink infrastructure\n });\n };\n sink[Symbol.asyncDispose] = async () => {\n await lastPromise;\n };\n return sink;\n}\n\n/**\n * Options for the {@link fingersCrossed} function.\n * @since 1.1.0\n */\nexport interface FingersCrossedOptions {\n /**\n * Minimum log level that triggers buffer flush.\n * When a log record at or above this level is received, all buffered\n * records are flushed to the wrapped sink.\n * @default `\"error\"`\n */\n readonly triggerLevel?: LogLevel;\n\n /**\n * Maximum buffer size before oldest records are dropped.\n * When the buffer exceeds this size, the oldest records are removed\n * to prevent unbounded memory growth.\n * @default `1000`\n */\n readonly maxBufferSize?: number;\n\n /**\n * Category isolation mode or custom matcher function.\n *\n * When `undefined` (default), all log records share a single buffer.\n *\n * When set to a mode string:\n *\n * - `\"descendant\"`: Flush child category buffers when parent triggers\n * - `\"ancestor\"`: Flush parent category buffers when child triggers\n * - `\"both\"`: Flush both parent and child category buffers\n *\n * When set to a function, it receives the trigger category and buffered\n * category and should return true if the buffered category should be flushed.\n *\n * @default `undefined` (no isolation, single global buffer)\n */\n readonly isolateByCategory?:\n | \"descendant\"\n | \"ancestor\"\n | \"both\"\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean);\n\n /**\n * Enable context-based buffer isolation.\n * When enabled, buffers are isolated based on specified context keys.\n * This is useful for scenarios like HTTP request tracing where logs\n * should be isolated per request.\n *\n * @example\n * ```typescript\n * fingersCrossed(sink, {\n * isolateByContext: { keys: ['requestId'] }\n * })\n * ```\n *\n * @example Combined with category isolation\n * ```typescript\n * fingersCrossed(sink, {\n * isolateByCategory: 'descendant',\n * isolateByContext: { keys: ['requestId', 'sessionId'] }\n * })\n * ```\n *\n * @default `undefined` (no context isolation)\n * @since 1.2.0\n */\n readonly isolateByContext?: {\n /**\n * Context keys to use for isolation.\n * Buffers will be separate for different combinations of these context values.\n */\n readonly keys: readonly string[];\n };\n}\n\n/**\n * Creates a sink that buffers log records until a trigger level is reached.\n * This pattern, known as \"fingers crossed\" logging, keeps detailed debug logs\n * in memory and only outputs them when an error or other significant event occurs.\n *\n * @example Basic usage with default settings\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink());\n * // Debug and info logs are buffered\n * // When an error occurs, all buffered logs + the error are output\n * ```\n *\n * @example Custom trigger level and buffer size\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink(), {\n * triggerLevel: \"warning\", // Trigger on warning or higher\n * maxBufferSize: 500 // Keep last 500 records\n * });\n * ```\n *\n * @example Category isolation\n * ```typescript\n * const sink = fingersCrossed(getConsoleSink(), {\n * isolateByCategory: \"descendant\" // Separate buffers per category\n * });\n * // Error in [\"app\"] triggers flush of [\"app\"] and [\"app\", \"module\"] buffers\n * // But not [\"other\"] buffer\n * ```\n *\n * @param sink The sink to wrap. Buffered records are sent to this sink when\n * triggered.\n * @param options Configuration options for the fingers crossed behavior.\n * @returns A sink that buffers records until the trigger level is reached.\n * @since 1.1.0\n */\nexport function fingersCrossed(\n sink: Sink,\n options: FingersCrossedOptions = {},\n): Sink {\n const triggerLevel = options.triggerLevel ?? \"error\";\n const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1000);\n const isolateByCategory = options.isolateByCategory;\n const isolateByContext = options.isolateByContext;\n\n // Validate trigger level early\n try {\n compareLogLevel(\"trace\", triggerLevel); // Test with any valid level\n } catch (error) {\n throw new TypeError(\n `Invalid triggerLevel: ${JSON.stringify(triggerLevel)}. ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n }\n\n // Helper functions for category matching\n function isDescendant(\n parent: readonly string[],\n child: readonly string[],\n ): boolean {\n if (parent.length === 0 || child.length === 0) return false; // Empty categories are isolated\n if (parent.length > child.length) return false;\n return parent.every((p, i) => p === child[i]);\n }\n\n function isAncestor(\n child: readonly string[],\n parent: readonly string[],\n ): boolean {\n if (child.length === 0 || parent.length === 0) return false; // Empty categories are isolated\n if (child.length < parent.length) return false;\n return parent.every((p, i) => p === child[i]);\n }\n\n // Determine matcher function based on isolation mode\n let shouldFlushBuffer:\n | ((\n triggerCategory: readonly string[],\n bufferedCategory: readonly string[],\n ) => boolean)\n | null = null;\n\n if (isolateByCategory) {\n if (typeof isolateByCategory === \"function\") {\n shouldFlushBuffer = isolateByCategory;\n } else {\n switch (isolateByCategory) {\n case \"descendant\":\n shouldFlushBuffer = (trigger, buffered) =>\n isDescendant(trigger, buffered);\n break;\n case \"ancestor\":\n shouldFlushBuffer = (trigger, buffered) =>\n isAncestor(trigger, buffered);\n break;\n case \"both\":\n shouldFlushBuffer = (trigger, buffered) =>\n isDescendant(trigger, buffered) || isAncestor(trigger, buffered);\n break;\n }\n }\n }\n\n // Helper functions for category serialization\n function getCategoryKey(category: readonly string[]): string {\n return JSON.stringify(category);\n }\n\n function parseCategoryKey(key: string): string[] {\n return JSON.parse(key);\n }\n\n // Helper function to extract context values from properties\n function getContextKey(properties: Record<string, unknown>): string {\n if (!isolateByContext || isolateByContext.keys.length === 0) {\n return \"\";\n }\n const contextValues: Record<string, unknown> = {};\n for (const key of isolateByContext.keys) {\n if (key in properties) {\n contextValues[key] = properties[key];\n }\n }\n return JSON.stringify(contextValues);\n }\n\n // Helper function to generate buffer key\n function getBufferKey(\n category: readonly string[],\n properties: Record<string, unknown>,\n ): string {\n const categoryKey = getCategoryKey(category);\n if (!isolateByContext) {\n return categoryKey;\n }\n const contextKey = getContextKey(properties);\n return `${categoryKey}:${contextKey}`;\n }\n\n // Helper function to parse buffer key\n function parseBufferKey(key: string): {\n category: string[];\n context: string;\n } {\n if (!isolateByContext) {\n return { category: parseCategoryKey(key), context: \"\" };\n }\n // Find the separator between category and context\n // The category part is JSON-encoded, so we need to find where it ends\n // We look for \"]:\" which indicates end of category array and start of context\n const separatorIndex = key.indexOf(\"]:\");\n if (separatorIndex === -1) {\n // No context part, entire key is category\n return { category: parseCategoryKey(key), context: \"\" };\n }\n const categoryPart = key.substring(0, separatorIndex + 1); // Include the ]\n const contextPart = key.substring(separatorIndex + 2); // Skip ]:\n return { category: parseCategoryKey(categoryPart), context: contextPart };\n }\n\n // Buffer management\n if (!isolateByCategory && !isolateByContext) {\n // Single global buffer\n const buffer: LogRecord[] = [];\n let triggered = false;\n\n return (record: LogRecord) => {\n if (triggered) {\n // Already triggered, pass through directly\n sink(record);\n return;\n }\n\n // Check if this record triggers flush\n if (compareLogLevel(record.level, triggerLevel) >= 0) {\n triggered = true;\n\n // Flush buffer\n for (const bufferedRecord of buffer) {\n sink(bufferedRecord);\n }\n buffer.length = 0;\n\n // Send trigger record\n sink(record);\n } else {\n // Buffer the record\n buffer.push(record);\n\n // Enforce max buffer size\n while (buffer.length > maxBufferSize) {\n buffer.shift();\n }\n }\n };\n } else {\n // Category and/or context-isolated buffers\n const buffers = new Map<string, LogRecord[]>();\n const triggered = new Set<string>();\n\n return (record: LogRecord) => {\n const bufferKey = getBufferKey(record.category, record.properties);\n\n // Check if this buffer is already triggered\n if (triggered.has(bufferKey)) {\n sink(record);\n return;\n }\n\n // Check if this record triggers flush\n if (compareLogLevel(record.level, triggerLevel) >= 0) {\n // Find all buffers that should be flushed\n const keysToFlush = new Set<string>();\n\n for (const [bufferedKey] of buffers) {\n if (bufferedKey === bufferKey) {\n keysToFlush.add(bufferedKey);\n } else {\n const { category: bufferedCategory, context: bufferedContext } =\n parseBufferKey(bufferedKey);\n const { context: triggerContext } = parseBufferKey(bufferKey);\n\n // Check context match\n let contextMatches = true;\n if (isolateByContext) {\n contextMatches = bufferedContext === triggerContext;\n }\n\n // Check category match\n let categoryMatches = false;\n if (!isolateByCategory) {\n // No category isolation, so all categories match if context matches\n categoryMatches = contextMatches;\n } else if (shouldFlushBuffer) {\n try {\n categoryMatches = shouldFlushBuffer(\n record.category,\n bufferedCategory,\n );\n } catch {\n // Ignore errors from custom matcher\n }\n } else {\n // Same category only\n categoryMatches = getCategoryKey(record.category) ===\n getCategoryKey(bufferedCategory);\n }\n\n // Both must match for the buffer to be flushed\n if (contextMatches && categoryMatches) {\n keysToFlush.add(bufferedKey);\n }\n }\n }\n\n // Flush matching buffers\n const allRecordsToFlush: LogRecord[] = [];\n for (const key of keysToFlush) {\n const buffer = buffers.get(key);\n if (buffer) {\n allRecordsToFlush.push(...buffer);\n buffers.delete(key);\n triggered.add(key);\n }\n }\n\n // Sort by timestamp to maintain chronological order\n allRecordsToFlush.sort((a, b) => a.timestamp - b.timestamp);\n\n // Flush all records\n for (const bufferedRecord of allRecordsToFlush) {\n sink(bufferedRecord);\n }\n\n // Mark trigger buffer as triggered and send trigger record\n triggered.add(bufferKey);\n sink(record);\n } else {\n // Buffer the record\n let buffer = buffers.get(bufferKey);\n if (!buffer) {\n buffer = [];\n buffers.set(bufferKey, buffer);\n }\n\n buffer.push(record);\n\n // Enforce max buffer size per buffer\n while (buffer.length > maxBufferSize) {\n buffer.shift();\n }\n }\n };\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logtape/logtape",
3
- "version": "1.1.0-dev.339+bcd57298",
3
+ "version": "1.2.0-dev.341+a87baf94",
4
4
  "description": "Simple logging library with zero dependencies for Deno/Node.js/Bun/browsers",
5
5
  "keywords": [
6
6
  "logging",
package/src/sink.test.ts CHANGED
@@ -1706,3 +1706,504 @@ test("fingersCrossed() - edge case: very deep category hierarchy", () => {
1706
1706
  assert(buffer.includes(parentRecord));
1707
1707
  assert(buffer.includes(deepError));
1708
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
+ });
package/src/sink.ts CHANGED
@@ -491,6 +491,38 @@ export interface FingersCrossedOptions {
491
491
  triggerCategory: readonly string[],
492
492
  bufferedCategory: readonly string[],
493
493
  ) => boolean);
494
+
495
+ /**
496
+ * Enable context-based buffer isolation.
497
+ * When enabled, buffers are isolated based on specified context keys.
498
+ * This is useful for scenarios like HTTP request tracing where logs
499
+ * should be isolated per request.
500
+ *
501
+ * @example
502
+ * ```typescript
503
+ * fingersCrossed(sink, {
504
+ * isolateByContext: { keys: ['requestId'] }
505
+ * })
506
+ * ```
507
+ *
508
+ * @example Combined with category isolation
509
+ * ```typescript
510
+ * fingersCrossed(sink, {
511
+ * isolateByCategory: 'descendant',
512
+ * isolateByContext: { keys: ['requestId', 'sessionId'] }
513
+ * })
514
+ * ```
515
+ *
516
+ * @default `undefined` (no context isolation)
517
+ * @since 1.2.0
518
+ */
519
+ readonly isolateByContext?: {
520
+ /**
521
+ * Context keys to use for isolation.
522
+ * Buffers will be separate for different combinations of these context values.
523
+ */
524
+ readonly keys: readonly string[];
525
+ };
494
526
  }
495
527
 
496
528
  /**
@@ -535,6 +567,7 @@ export function fingersCrossed(
535
567
  const triggerLevel = options.triggerLevel ?? "error";
536
568
  const maxBufferSize = Math.max(0, options.maxBufferSize ?? 1000);
537
569
  const isolateByCategory = options.isolateByCategory;
570
+ const isolateByContext = options.isolateByContext;
538
571
 
539
572
  // Validate trigger level early
540
573
  try {
@@ -604,8 +637,56 @@ export function fingersCrossed(
604
637
  return JSON.parse(key);
605
638
  }
606
639
 
640
+ // Helper function to extract context values from properties
641
+ function getContextKey(properties: Record<string, unknown>): string {
642
+ if (!isolateByContext || isolateByContext.keys.length === 0) {
643
+ return "";
644
+ }
645
+ const contextValues: Record<string, unknown> = {};
646
+ for (const key of isolateByContext.keys) {
647
+ if (key in properties) {
648
+ contextValues[key] = properties[key];
649
+ }
650
+ }
651
+ return JSON.stringify(contextValues);
652
+ }
653
+
654
+ // Helper function to generate buffer key
655
+ function getBufferKey(
656
+ category: readonly string[],
657
+ properties: Record<string, unknown>,
658
+ ): string {
659
+ const categoryKey = getCategoryKey(category);
660
+ if (!isolateByContext) {
661
+ return categoryKey;
662
+ }
663
+ const contextKey = getContextKey(properties);
664
+ return `${categoryKey}:${contextKey}`;
665
+ }
666
+
667
+ // Helper function to parse buffer key
668
+ function parseBufferKey(key: string): {
669
+ category: string[];
670
+ context: string;
671
+ } {
672
+ if (!isolateByContext) {
673
+ return { category: parseCategoryKey(key), context: "" };
674
+ }
675
+ // Find the separator between category and context
676
+ // The category part is JSON-encoded, so we need to find where it ends
677
+ // We look for "]:" which indicates end of category array and start of context
678
+ const separatorIndex = key.indexOf("]:");
679
+ if (separatorIndex === -1) {
680
+ // No context part, entire key is category
681
+ return { category: parseCategoryKey(key), context: "" };
682
+ }
683
+ const categoryPart = key.substring(0, separatorIndex + 1); // Include the ]
684
+ const contextPart = key.substring(separatorIndex + 2); // Skip ]:
685
+ return { category: parseCategoryKey(categoryPart), context: contextPart };
686
+ }
687
+
607
688
  // Buffer management
608
- if (!isolateByCategory) {
689
+ if (!isolateByCategory && !isolateByContext) {
609
690
  // Single global buffer
610
691
  const buffer: LogRecord[] = [];
611
692
  let triggered = false;
@@ -640,15 +721,15 @@ export function fingersCrossed(
640
721
  }
641
722
  };
642
723
  } else {
643
- // Category-isolated buffers
724
+ // Category and/or context-isolated buffers
644
725
  const buffers = new Map<string, LogRecord[]>();
645
726
  const triggered = new Set<string>();
646
727
 
647
728
  return (record: LogRecord) => {
648
- const categoryKey = getCategoryKey(record.category);
729
+ const bufferKey = getBufferKey(record.category, record.properties);
649
730
 
650
- // Check if this category is already triggered
651
- if (triggered.has(categoryKey)) {
731
+ // Check if this buffer is already triggered
732
+ if (triggered.has(bufferKey)) {
652
733
  sink(record);
653
734
  return;
654
735
  }
@@ -659,16 +740,42 @@ export function fingersCrossed(
659
740
  const keysToFlush = new Set<string>();
660
741
 
661
742
  for (const [bufferedKey] of buffers) {
662
- if (bufferedKey === categoryKey) {
743
+ if (bufferedKey === bufferKey) {
663
744
  keysToFlush.add(bufferedKey);
664
- } else if (shouldFlushBuffer) {
665
- const bufferedCategory = parseCategoryKey(bufferedKey);
666
- try {
667
- if (shouldFlushBuffer(record.category, bufferedCategory)) {
668
- keysToFlush.add(bufferedKey);
745
+ } else {
746
+ const { category: bufferedCategory, context: bufferedContext } =
747
+ parseBufferKey(bufferedKey);
748
+ const { context: triggerContext } = parseBufferKey(bufferKey);
749
+
750
+ // Check context match
751
+ let contextMatches = true;
752
+ if (isolateByContext) {
753
+ contextMatches = bufferedContext === triggerContext;
754
+ }
755
+
756
+ // Check category match
757
+ let categoryMatches = false;
758
+ if (!isolateByCategory) {
759
+ // No category isolation, so all categories match if context matches
760
+ categoryMatches = contextMatches;
761
+ } else if (shouldFlushBuffer) {
762
+ try {
763
+ categoryMatches = shouldFlushBuffer(
764
+ record.category,
765
+ bufferedCategory,
766
+ );
767
+ } catch {
768
+ // Ignore errors from custom matcher
669
769
  }
670
- } catch {
671
- // Ignore errors from custom matcher
770
+ } else {
771
+ // Same category only
772
+ categoryMatches = getCategoryKey(record.category) ===
773
+ getCategoryKey(bufferedCategory);
774
+ }
775
+
776
+ // Both must match for the buffer to be flushed
777
+ if (contextMatches && categoryMatches) {
778
+ keysToFlush.add(bufferedKey);
672
779
  }
673
780
  }
674
781
  }
@@ -692,20 +799,20 @@ export function fingersCrossed(
692
799
  sink(bufferedRecord);
693
800
  }
694
801
 
695
- // Mark trigger category as triggered and send trigger record
696
- triggered.add(categoryKey);
802
+ // Mark trigger buffer as triggered and send trigger record
803
+ triggered.add(bufferKey);
697
804
  sink(record);
698
805
  } else {
699
806
  // Buffer the record
700
- let buffer = buffers.get(categoryKey);
807
+ let buffer = buffers.get(bufferKey);
701
808
  if (!buffer) {
702
809
  buffer = [];
703
- buffers.set(categoryKey, buffer);
810
+ buffers.set(bufferKey, buffer);
704
811
  }
705
812
 
706
813
  buffer.push(record);
707
814
 
708
- // Enforce max buffer size per category
815
+ // Enforce max buffer size per buffer
709
816
  while (buffer.length > maxBufferSize) {
710
817
  buffer.shift();
711
818
  }