loggily 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,15 +49,21 @@ const colors = {
49
49
  function createFileWriter(filePath, options = {}) {
50
50
  const bufferSize = options.bufferSize ?? 4096;
51
51
  const flushInterval = options.flushInterval ?? 100;
52
+ const fs = options.__fs ?? {
53
+ openSync,
54
+ writeSync,
55
+ closeSync
56
+ };
52
57
  let buffer = "";
53
58
  let fd = null;
54
59
  let timer = null;
55
60
  let closed = false;
56
- fd = openSync(filePath, "a");
61
+ fd = fs.openSync(filePath, "a");
57
62
  /** Flush buffer contents to disk synchronously */
58
63
  function flush() {
59
64
  if (buffer.length === 0 || fd === null) return;
60
- writeSync(fd, buffer);
65
+ const data = buffer;
66
+ fs.writeSync(fd, data);
61
67
  buffer = "";
62
68
  }
63
69
  timer = setInterval(flush, flushInterval);
@@ -82,7 +88,7 @@ function createFileWriter(filePath, options = {}) {
82
88
  flush();
83
89
  } catch {} finally {
84
90
  if (fd !== null) {
85
- closeSync(fd);
91
+ fs.closeSync(fd);
86
92
  fd = null;
87
93
  }
88
94
  process.removeListener("exit", exitHandler);
@@ -149,7 +155,8 @@ function resetIdCounters() {
149
155
  *
150
156
  * @example
151
157
  * ```typescript
152
- * const span = log.span("http-request")
158
+ * const span = log.span?.("http-request")
159
+ * if (!span) return
153
160
  * const header = traceparent(span.spanData)
154
161
  * // → "00-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6-1a2b3c4d5e6f7a8b-01"
155
162
  * fetch(url, { headers: { traceparent: header } })
@@ -195,6 +202,211 @@ function shouldSample() {
195
202
  return Math.random() < sampleRate;
196
203
  }
197
204
  //#endregion
205
+ //#region src/console-sinks.ts
206
+ /**
207
+ * Console sinks — runtime-aware structured output to console.
208
+ *
209
+ * Two sinks, one contract: `(event: Event) => void`. They do NOT pre-format
210
+ * events into a single ANSI string. Instead they spread structured arguments
211
+ * to `console.*` so the platform can render rich output:
212
+ *
213
+ * Terminal (Node / Bun):
214
+ * console.info(ansiPrefix, message, ...userArgs)
215
+ * — util.format keeps objects inspectable in devtools
216
+ *
217
+ * Browser (Chrome / Firefox / Safari DevTools):
218
+ * console.info("%c<level> %c<namespace>", levelCss, nsCss, message, ...userArgs)
219
+ * — DevTools renders colored prefix and keeps every user arg as an
220
+ * expandable, clickable object.
221
+ *
222
+ * Source-location preservation:
223
+ * We call `console.<level>(...)` from arrow-function sinks. DevTools attribute
224
+ * the log line to the caller's frame, not this file, as long as no wrapper
225
+ * closes over a bound console reference. `Function.prototype.bind.call(console.info, ...)`
226
+ * works too but defeats `vi.spyOn(console, 'info')` because bind captures
227
+ * the function at bind time. Plain arrow functions re-read `console.info`
228
+ * on each call — mockable AND location-preserving.
229
+ *
230
+ * The sinks consume `event.userArgs` (raw user-supplied data/errors) when
231
+ * present, falling back to `event.props` so they still work with code paths
232
+ * that have not yet been migrated.
233
+ */
234
+ /**
235
+ * True when running inside a browser-like environment. We check for the
236
+ * presence of a `window` with `document` — the Node DOM test env (jsdom)
237
+ * also matches, but that is the correct behaviour for DevTools-style output.
238
+ */
239
+ function isBrowserRuntime() {
240
+ return typeof globalThis?.window !== "undefined" && typeof globalThis.document !== "undefined";
241
+ }
242
+ function timeStr(time) {
243
+ return new Date(time).toISOString().split("T")[1]?.split(".")[0] ?? "";
244
+ }
245
+ function levelLabel(level) {
246
+ switch (level) {
247
+ case "trace": return "TRACE";
248
+ case "debug": return "DEBUG";
249
+ case "info": return "INFO";
250
+ case "warn": return "WARN";
251
+ case "error": return "ERROR";
252
+ }
253
+ }
254
+ /**
255
+ * Return the raw user-supplied data attached to the event, in call order.
256
+ * Prefers the explicit `userArgs` field (new path). Falls back to `props`
257
+ * (coerced to a single trailing object) for events emitted by older code or
258
+ * by `writeSpan`. Filters out `undefined` entries.
259
+ */
260
+ function userArgsOf(event) {
261
+ if ("userArgs" in event && Array.isArray(event.userArgs)) return event.userArgs.filter((v) => v !== void 0);
262
+ if (event.props && Object.keys(event.props).length > 0) return [event.props];
263
+ return [];
264
+ }
265
+ /**
266
+ * Terminal (ANSI) sink. Emits `[ansiPrefix, message, ...userArgs]` so that:
267
+ * - stdout gets a colored prefix
268
+ * - util.format leaves objects inspectable
269
+ * - vi.spyOn(console, 'info') intercepts calls (arrows re-read console)
270
+ *
271
+ * Spans and JSON format still go through the pre-formatted single-arg path —
272
+ * their consumers are log aggregators, not humans.
273
+ */
274
+ function createTerminalConsoleSink(format = "console") {
275
+ if (format === "json") return (event) => routeSingle(event, formatJSONEvent(event));
276
+ return (event) => {
277
+ if (event.kind === "span") {
278
+ writeStderrLine(formatConsoleEvent(event));
279
+ return;
280
+ }
281
+ const prefix = `${colors.dim(timeStr(event.time))} ${levelAnsi(event.level)} ${colors.cyan(event.namespace)}`;
282
+ const args = userArgsOf(event);
283
+ invokeForLevel(event.level, prefix, event.message, ...args);
284
+ };
285
+ }
286
+ /** Emit one line to process.stderr when available (no-op in browser). */
287
+ function writeStderrLine(text) {
288
+ const p = typeof process !== "undefined" ? process : void 0;
289
+ if (p?.stderr && typeof p.stderr.write === "function") {
290
+ p.stderr.write(text + "\n");
291
+ return;
292
+ }
293
+ console.info(text);
294
+ }
295
+ function levelAnsi(level) {
296
+ switch (level) {
297
+ case "trace": return colors.dim("TRACE");
298
+ case "debug": return colors.dim("DEBUG");
299
+ case "info": return colors.blue("INFO");
300
+ case "warn": return colors.yellow("WARN");
301
+ case "error": return colors.red("ERROR");
302
+ }
303
+ }
304
+ /**
305
+ * Browser (DevTools) sink. Uses `%c` CSS format specifiers so DevTools
306
+ * renders a colored level + namespace prefix, then spreads user args raw so
307
+ * DevTools keeps them as expandable object references (clickable source
308
+ * locations for Error, drill-in for plain objects).
309
+ *
310
+ * For JSON format we still emit a single string — consumers that explicitly
311
+ * asked for JSON want machine-readable output, not DevTools theatrics.
312
+ */
313
+ function createBrowserConsoleSink(format = "console") {
314
+ if (format === "json") return (event) => routeSingle(event, formatJSONEvent(event));
315
+ return (event) => {
316
+ if (event.kind === "span") {
317
+ const spanTemplate = `%c%s %cSPAN %c%s %c(%sms)`;
318
+ const args = userArgsOf(event);
319
+ const spanPropsString = args.length > 0 ? ` ${safeStringify(Object.assign({}, ...args.filter(isPlainRecord)))}` : "";
320
+ const { durationLabel } = { durationLabel: String(event.duration) };
321
+ invokeForLevel("info", spanTemplate + (spanPropsString ? "%s" : ""), cssDim(), timeStr(event.time), cssSpan(), cssNamespace(), event.namespace, cssDim(), durationLabel, ...spanPropsString ? [cssDim(), spanPropsString] : []);
322
+ return;
323
+ }
324
+ const template = `%c%s %c%s %c%s`;
325
+ const args = userArgsOf(event);
326
+ invokeForLevel(event.level, template, cssDim(), timeStr(event.time), cssLevel(event.level), levelLabel(event.level), cssNamespace(), event.namespace, event.message, ...args);
327
+ };
328
+ }
329
+ function isPlainRecord(v) {
330
+ return typeof v === "object" && v !== null && !Array.isArray(v);
331
+ }
332
+ function cssDim() {
333
+ return "color: #888";
334
+ }
335
+ function cssNamespace() {
336
+ return "color: #0aa; font-weight: bold";
337
+ }
338
+ function cssSpan() {
339
+ return "color: #a0a; font-weight: bold";
340
+ }
341
+ function cssLevel(level) {
342
+ switch (level) {
343
+ case "trace":
344
+ case "debug": return "color: #888; font-weight: bold";
345
+ case "info": return "color: #36f; font-weight: bold";
346
+ case "warn": return "color: #b80; font-weight: bold";
347
+ case "error": return "color: #c33; font-weight: bold";
348
+ }
349
+ }
350
+ /**
351
+ * Dispatch to the right console method for the event's level.
352
+ * We intentionally use arrow-style invocation rather than
353
+ * `Function.prototype.bind.call(console.info, console, ...)` so that:
354
+ * 1. Tests can `vi.spyOn(console, 'info')` AFTER the sink is created.
355
+ * 2. DevTools attribute the log line to the caller's source location
356
+ * (arrows don't appear in the stack between the call and `console.*`).
357
+ */
358
+ function invokeForLevel(level, ...args) {
359
+ switch (level) {
360
+ case "trace":
361
+ case "debug":
362
+ console.debug(...args);
363
+ return;
364
+ case "info":
365
+ console.info(...args);
366
+ return;
367
+ case "warn":
368
+ console.warn(...args);
369
+ return;
370
+ case "error":
371
+ console.error(...args);
372
+ return;
373
+ }
374
+ }
375
+ /**
376
+ * Route an already-formatted single string to the correct console level.
377
+ * Used for JSON format and for span events where we preserve the pre-formatted
378
+ * shape.
379
+ */
380
+ function routeSingle(event, text) {
381
+ if (event.kind === "span") {
382
+ console.info(text);
383
+ return;
384
+ }
385
+ switch (event.level) {
386
+ case "trace":
387
+ case "debug":
388
+ console.debug(text);
389
+ return;
390
+ case "info":
391
+ console.info(text);
392
+ return;
393
+ case "warn":
394
+ console.warn(text);
395
+ return;
396
+ case "error":
397
+ console.error(text);
398
+ return;
399
+ }
400
+ }
401
+ /**
402
+ * Pick the right sink for the current runtime. Browsers get `%c` CSS, Node
403
+ * gets ANSI. Both emit multi-arg spreads so the platform can render objects
404
+ * as expandable references.
405
+ */
406
+ function createConsoleSink$1(format = "console") {
407
+ return isBrowserRuntime() ? createBrowserConsoleSink(format) : createTerminalConsoleSink(format);
408
+ }
409
+ //#endregion
198
410
  //#region src/pipeline.ts
199
411
  const LOG_LEVEL_PRIORITY = {
200
412
  trace: 0,
@@ -208,10 +420,6 @@ const _process = typeof process !== "undefined" ? process : void 0;
208
420
  function getEnv(key) {
209
421
  return _process?.env?.[key];
210
422
  }
211
- function writeStderr(text) {
212
- if (_process?.stderr?.write) _process.stderr.write(text + "\n");
213
- else console.error(text);
214
- }
215
423
  /** Serialize Error.cause chains up to a max depth */
216
424
  function serializeCause(cause, maxDepth = 3) {
217
425
  if (maxDepth <= 0 || cause === void 0 || cause === null) return void 0;
@@ -324,38 +532,12 @@ function parseNsFilter(ns) {
324
532
  };
325
533
  }
326
534
  /**
327
- * Write formatted text to console using the appropriate log level.
328
- *
329
- * IMPORTANT: We use Function.bind() to preserve caller source locations in
330
- * browser DevTools. When you click a log line in DevTools, it shows where
331
- * YOUR code called log.info?.(), not where pipeline.ts called console.info().
332
- * DO NOT replace bind() with direct console.info(text) calls — it breaks
333
- * source location tracking in browsers.
535
+ * Console sink used inside the pipeline builder. Delegates to the structured
536
+ * console sink (browser %c or terminal multi-arg) so the pipeline preserves
537
+ * expandable user args end-to-end.
334
538
  */
335
- function writeToConsole(text, event) {
336
- if (event.kind === "span") {
337
- writeStderr(text);
338
- return;
339
- }
340
- switch (event.level) {
341
- case "trace":
342
- case "debug":
343
- Function.prototype.bind.call(console.debug, console, text)();
344
- break;
345
- case "info":
346
- Function.prototype.bind.call(console.info, console, text)();
347
- break;
348
- case "warn":
349
- Function.prototype.bind.call(console.warn, console, text)();
350
- break;
351
- case "error":
352
- Function.prototype.bind.call(console.error, console, text)();
353
- break;
354
- }
355
- }
356
539
  function createConsoleSink(format) {
357
- const formatter = format === "json" ? formatJSONEvent : formatConsoleEvent;
358
- return (event) => writeToConsole(formatter(event), event);
540
+ return createConsoleSink$1(format);
359
541
  }
360
542
  function createFileSink(path, format) {
361
543
  const writer = createFileWriter(path);
@@ -493,8 +675,15 @@ function buildPipeline(elements, parentConfig) {
493
675
  }
494
676
  for (const branch of branches) branch.dispatch(e);
495
677
  };
678
+ const spanEnabledForNamespace = (namespace) => {
679
+ if (!spansEnabled) return false;
680
+ if (outputs.some((output) => !output.nsFilter || output.nsFilter(namespace))) return true;
681
+ if (branches.some((branch) => branch.spanEnabled(namespace))) return true;
682
+ return stages.length > 0;
683
+ };
496
684
  return {
497
685
  dispatch,
686
+ spanEnabled: spanEnabledForNamespace,
498
687
  level: config.level,
499
688
  dispose: () => {
500
689
  for (const d of disposables) d();
@@ -573,6 +762,12 @@ function readEnvTrace() {
573
762
  * ])
574
763
  * log.info?.('server started', { port: 3000 })
575
764
  */
765
+ const SPAN_ENABLED = Symbol("loggily.spanEnabled");
766
+ function spanIsEnabled(logger, namespace) {
767
+ if (collectSpans) return true;
768
+ const checker = logger[SPAN_ENABLED];
769
+ return checker ? checker(namespace) : true;
770
+ }
576
771
  function resetIds() {
577
772
  resetIdCounters();
578
773
  }
@@ -638,6 +833,7 @@ function createLoggerImpl(name, props, pipeline) {
638
833
  const emitLog = (level, msgOrError, dataOrMsg, extraData) => {
639
834
  let message;
640
835
  let data;
836
+ const userArgs = [];
641
837
  if (msgOrError instanceof Error) {
642
838
  const err = msgOrError;
643
839
  const contextTags = _getContextTags?.() ?? {};
@@ -665,6 +861,8 @@ function createLoggerImpl(name, props, pipeline) {
665
861
  error_cause: err.cause !== void 0 ? serializeCause(err.cause) : void 0
666
862
  };
667
863
  }
864
+ userArgs.push(err);
865
+ if (data && Object.keys(data).length > 0) userArgs.push(data);
668
866
  } else {
669
867
  message = resolveMessage(msgOrError);
670
868
  const contextTags = _getContextTags?.();
@@ -676,6 +874,7 @@ function createLoggerImpl(name, props, pipeline) {
676
874
  ...props,
677
875
  ...dataOrMsg
678
876
  } : void 0;
877
+ if (data && Object.keys(data).length > 0) userArgs.push(data);
679
878
  }
680
879
  const event = {
681
880
  kind: "log",
@@ -683,7 +882,8 @@ function createLoggerImpl(name, props, pipeline) {
683
882
  namespace: name,
684
883
  level,
685
884
  message,
686
- props: data
885
+ props: data,
886
+ userArgs
687
887
  };
688
888
  pipeline.dispatch(event);
689
889
  };
@@ -693,6 +893,9 @@ function createLoggerImpl(name, props, pipeline) {
693
893
  get level() {
694
894
  return pipeline.level;
695
895
  },
896
+ [SPAN_ENABLED](namespace) {
897
+ return pipeline.spanEnabled(namespace);
898
+ },
696
899
  dispatch(event) {
697
900
  pipeline.dispatch(event);
698
901
  },
@@ -757,8 +960,9 @@ function augmentWithSpans(logger, parentSpanId, traceId, traceSampled) {
757
960
  traceId,
758
961
  traceSampled
759
962
  };
963
+ const spanMethod = spanIsEnabled(logger, logger.name) ? createSpanMethod(logger, spanState) : void 0;
760
964
  return new Proxy(logger, { get(target, prop) {
761
- if (prop === "span") return createSpanMethod(target, spanState);
965
+ if (prop === "span") return spanMethod;
762
966
  if (prop === "child") return function child(namespaceOrContext, childProps) {
763
967
  return augmentWithSpans(target.child(namespaceOrContext, childProps), spanState.parentSpanId, spanState.traceId, spanState.traceSampled);
764
968
  };
@@ -796,7 +1000,8 @@ function createSpanMethod(logger, spanState) {
796
1000
  startTime: Date.now(),
797
1001
  endTime: null,
798
1002
  duration: null,
799
- attrs: {}
1003
+ attrs: {},
1004
+ laps: []
800
1005
  };
801
1006
  const spanAugmented = augmentWithSpans(logger.child(namespace ?? "", resolvedChildProps), newSpanId, finalTraceId, sampled);
802
1007
  _enterContext?.(newSpanId, finalTraceId, resolvedParentId);
@@ -814,6 +1019,7 @@ function createSpanMethod(logger, spanState) {
814
1019
  }), { ...newSpanData.attrs }));
815
1020
  _exitContext?.(newSpanId);
816
1021
  if (sampled) {
1022
+ const lapsProp = newSpanData.laps.length > 0 ? Object.fromEntries(newSpanData.laps.map((l) => [l.name, l.deltaMs])) : void 0;
817
1023
  const spanEvent = {
818
1024
  kind: "span",
819
1025
  time: newSpanData.endTime,
@@ -822,7 +1028,8 @@ function createSpanMethod(logger, spanState) {
822
1028
  duration: newSpanData.duration,
823
1029
  props: {
824
1030
  ...mergedProps,
825
- ...newSpanData.attrs
1031
+ ...newSpanData.attrs,
1032
+ ...lapsProp ? { laps: lapsProp } : {}
826
1033
  },
827
1034
  spanId: newSpanData.id,
828
1035
  traceId: newSpanData.traceId,
@@ -831,6 +1038,16 @@ function createSpanMethod(logger, spanState) {
831
1038
  logger.dispatch(spanEvent);
832
1039
  }
833
1040
  };
1041
+ const lapImpl = (name) => {
1042
+ const elapsedMs = Date.now() - newSpanData.startTime;
1043
+ const lastLap = newSpanData.laps[newSpanData.laps.length - 1];
1044
+ const deltaMs = lastLap ? elapsedMs - lastLap.elapsedMs : elapsedMs;
1045
+ newSpanData.laps.push({
1046
+ name,
1047
+ elapsedMs,
1048
+ deltaMs
1049
+ });
1050
+ };
834
1051
  const spanDataProxy = createSpanDataProxy(() => ({
835
1052
  id: newSpanData.id,
836
1053
  traceId: newSpanData.traceId,
@@ -847,6 +1064,7 @@ function createSpanMethod(logger, spanState) {
847
1064
  if (prop === "end") return () => {
848
1065
  if (newSpanData.endTime === null) currentDispose();
849
1066
  };
1067
+ if (prop === "lap") return lapImpl;
850
1068
  if (prop === "name") return childName;
851
1069
  if (prop === "props") return Object.freeze({ ...mergedProps });
852
1070
  return target[prop];
@@ -938,6 +1156,15 @@ function withEnvDefaults() {
938
1156
  */
939
1157
  function applyNamespaceGating(logger) {
940
1158
  return new Proxy(logger, { get(target, prop) {
1159
+ if (prop === SPAN_ENABLED) return (namespace) => {
1160
+ if (collectSpans) return true;
1161
+ const trace = currentTrace();
1162
+ if (!trace.enabled) return false;
1163
+ if (trace.filter && !trace.filter(namespace)) return false;
1164
+ const ns = currentNs();
1165
+ if (ns && !ns(namespace)) return false;
1166
+ return true;
1167
+ };
941
1168
  if (typeof prop === "string" && prop in LOG_LEVEL_PRIORITY && prop !== "silent") {
942
1169
  const nsLevel = readEnvLevelForNamespace(target.name);
943
1170
  if (LOG_LEVEL_PRIORITY[prop] < LOG_LEVEL_PRIORITY[nsLevel]) return;
@@ -966,14 +1193,23 @@ function createEnvPipeline() {
966
1193
  }
967
1194
  const ns = currentNs();
968
1195
  if (ns && !ns(event.namespace)) return;
969
- const text = (currentFormat() === "json" ? formatJSONEvent : formatConsoleEvent)(event);
1196
+ const format = currentFormat();
1197
+ const text = (format === "json" ? formatJSONEvent : formatConsoleEvent)(event);
970
1198
  const lvl = event.kind === "log" ? event.level : "span";
971
- for (const w of _writers) w(text, lvl);
972
- if (!_suppressConsole) writeToConsole(text, event);
1199
+ for (const w of _writers) w(text, lvl, event.namespace, event);
1200
+ if (!_suppressConsole) createConsoleSink$1(format)(event);
973
1201
  fileSink?.(event);
974
1202
  };
975
1203
  return {
976
1204
  dispatch,
1205
+ spanEnabled(namespace) {
1206
+ const trace = currentTrace();
1207
+ if (!trace.enabled) return false;
1208
+ if (trace.filter && !trace.filter(namespace)) return false;
1209
+ const ns = currentNs();
1210
+ if (ns && !ns(namespace)) return false;
1211
+ return true;
1212
+ },
977
1213
  get level() {
978
1214
  return currentLevel();
979
1215
  },
@@ -1048,7 +1284,21 @@ function setOutputMode(_mode) {
1048
1284
  function getOutputMode() {
1049
1285
  return "console";
1050
1286
  }
1051
- function addWriter(writer) {
1287
+ function addWriter(arg1, arg2) {
1288
+ if (typeof arg1 === "function") return _addRawWriter(arg1);
1289
+ if (!arg2) throw new Error("addWriter: writer fn required when first argument is a config object");
1290
+ const config = arg1;
1291
+ const matches = config.ns ? parseNsFilter(config.ns) : null;
1292
+ const minPriority = config.level ? LOG_LEVEL_PRIORITY[config.level] : null;
1293
+ return _addRawWriter((formatted, level, namespace, event) => {
1294
+ if (matches && !matches(namespace)) return;
1295
+ if (minPriority !== null) {
1296
+ if ((LOG_LEVEL_PRIORITY[level] ?? LOG_LEVEL_PRIORITY.info) < minPriority) return;
1297
+ }
1298
+ arg2(formatted, level, namespace, event);
1299
+ });
1300
+ }
1301
+ function _addRawWriter(writer) {
1052
1302
  _writers.push(writer);
1053
1303
  return () => {
1054
1304
  const i = _writers.indexOf(writer);
@@ -1071,4 +1321,4 @@ function writeSpan(namespace, duration, attrs) {
1071
1321
  //#endregion
1072
1322
  export { withEnvDefaults as A, setSampleRate as B, setOutputMode as C, startCollecting as D, spansAreEnabled as E, safeStringify as F, createFileWriter as H, serializeCause as I, getIdFormat as L, writeSpan as M, LOG_LEVEL_PRIORITY as N, stopCollecting as O, buildPipeline as P, getSampleRate as R, setLogLevel as S, setTraceFilter as T, traceparent as V, getTraceFilter as _, baseCreateLogger as a, setDebugFilter as b, createSpanDataProxy as c, enableSpans as d, getCollectedSpans as f, getOutputMode as g, getLogLevel as h, addWriter as i, withSpans as j, withConfigMetrics as k, createTestLogger as l, getLogFormat as m, _setContextHooks as n, clearCollectedSpans as o, getDebugFilter as p, _setLogFileWriterFactory as r, createLogger as s, _clearContextHooks as t, disableSpans as u, pipe as v, setSuppressConsole as w, setLogFormat as x, resetIds as y, setIdFormat as z };
1073
1323
 
1074
- //# sourceMappingURL=core-B3pox577.mjs.map
1324
+ //# sourceMappingURL=core-Byd3DY71.mjs.map