plslog 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -6,6 +6,181 @@ function isBase64(link) {
6
6
  return link.startsWith("data:image/") && link.includes(";base64");
7
7
  }
8
8
  var Formatter = class {
9
+ /**
10
+ * Check if a field should be filtered based on configured rules
11
+ */
12
+ static shouldFilterField(key, value, path, filterFields) {
13
+ if (!filterFields || filterFields.length === 0) {
14
+ return false;
15
+ }
16
+ for (const filter of filterFields) {
17
+ if (typeof filter === "string" && key === filter) {
18
+ return true;
19
+ }
20
+ if (filter instanceof RegExp && filter.test(key)) {
21
+ return true;
22
+ }
23
+ if (typeof filter === "function") {
24
+ const shouldShow = filter(key, value, path);
25
+ if (shouldShow === false) {
26
+ return true;
27
+ }
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Check if a field should be included based on pick/omit rules
34
+ */
35
+ static shouldIncludeField(key, value, path, options) {
36
+ if (options?.pick && options.pick.length > 0) {
37
+ const pathWithoutIndices = path.filter((p) => !p.startsWith("["));
38
+ const fullPath = [...pathWithoutIndices, key].join(".");
39
+ const isInPickList = options.pick.some((pickPath) => {
40
+ if (pickPath.endsWith(".*")) {
41
+ const prefix = pickPath.slice(0, -2);
42
+ return fullPath.startsWith(prefix + ".") || fullPath === prefix;
43
+ }
44
+ if (!pickPath.includes(".") && key === pickPath) {
45
+ return true;
46
+ }
47
+ if (fullPath === pickPath) {
48
+ return true;
49
+ }
50
+ if (pickPath.startsWith(fullPath + ".")) {
51
+ return true;
52
+ }
53
+ if (fullPath.startsWith(pickPath + ".")) {
54
+ return true;
55
+ }
56
+ return false;
57
+ });
58
+ if (!isInPickList) {
59
+ return { include: false, filtered: false };
60
+ }
61
+ }
62
+ if (options?.omit && options.omit.length > 0) {
63
+ const shouldOmit = this.shouldFilterField(key, value, path, options.omit);
64
+ if (shouldOmit) {
65
+ return { include: true, filtered: true };
66
+ }
67
+ }
68
+ return { include: true, filtered: false };
69
+ }
70
+ /**
71
+ * Get the replacement value for a filtered field
72
+ */
73
+ static getFilteredValue(value, mode = "redact", replacement = "***REDACTED***") {
74
+ switch (mode) {
75
+ case "redact":
76
+ return replacement;
77
+ case "type":
78
+ if (value === null) return "[null]";
79
+ if (value === void 0) return "[undefined]";
80
+ if (Array.isArray(value)) return `[Array(${value.length})]`;
81
+ return `[${typeof value}]`;
82
+ case "length":
83
+ if (typeof value === "string") return `[${value.length} chars]`;
84
+ if (Array.isArray(value)) return `[${value.length} items]`;
85
+ if (typeof value === "object" && value !== null) {
86
+ return `[${Object.keys(value).length} keys]`;
87
+ }
88
+ return `[${typeof value}]`;
89
+ case "hide":
90
+ return void 0;
91
+ // Will be removed by caller
92
+ default:
93
+ return replacement;
94
+ }
95
+ }
96
+ /**
97
+ * Sanitize sensitive data from stack traces
98
+ */
99
+ static sanitizeStackTrace(stack, customPatterns) {
100
+ const defaultPatterns = [
101
+ /([?&])(token|key|secret|password|auth|api[_-]?key|access[_-]?token|client[_-]?secret)=[^&\s)#]*/gi,
102
+ /\/\/[^/]*:[^@]*@/g,
103
+ // Basic auth in URLs (user:pass@domain)
104
+ /(bearer\s+)[a-zA-Z0-9\-._~+/]+=*/gi
105
+ // Bearer tokens
106
+ ];
107
+ const allPatterns = [...defaultPatterns, ...customPatterns || []];
108
+ let sanitized = stack;
109
+ allPatterns.forEach((pattern) => {
110
+ sanitized = sanitized.replace(pattern, (match, p1) => {
111
+ if (p1) {
112
+ return `${p1}***REDACTED***`;
113
+ }
114
+ return "***REDACTED***";
115
+ });
116
+ });
117
+ return sanitized;
118
+ }
119
+ /**
120
+ * Serialize Error objects with all relevant properties (name, message, stack, cause)
121
+ */
122
+ static serializeError(error, config, currentDepth = 0) {
123
+ const maxCauseDepth = config?.maxCauseDepth ?? 10;
124
+ if (currentDepth > maxCauseDepth) {
125
+ return "[Max Cause Depth Reached]";
126
+ }
127
+ const includeStack = config?.includeStack ?? true;
128
+ const includeCause = config?.includeCause ?? true;
129
+ const stackFrameLimit = config?.stackFrameLimit;
130
+ const sanitizeStack = config?.sanitizeStack ?? false;
131
+ const serialized = {
132
+ name: error.name,
133
+ message: error.message
134
+ };
135
+ if ("code" in error) {
136
+ const errorWithCode = error;
137
+ if (typeof errorWithCode.code === "number") {
138
+ serialized.code = errorWithCode.code;
139
+ }
140
+ }
141
+ if (includeStack && error.stack) {
142
+ let stack = error.stack;
143
+ if (sanitizeStack) {
144
+ stack = this.sanitizeStackTrace(stack, config?.sanitizePatterns);
145
+ }
146
+ if (stackFrameLimit !== void 0 && stackFrameLimit > 0) {
147
+ const lines = stack.split("\n");
148
+ if (lines.length > stackFrameLimit) {
149
+ stack = lines.slice(0, stackFrameLimit).join("\n") + "\n... (truncated)";
150
+ }
151
+ }
152
+ serialized.stack = stack;
153
+ }
154
+ if (includeCause && error.cause !== void 0) {
155
+ if (error.cause instanceof Error) {
156
+ const serializedCause = this.serializeError(error.cause, config, currentDepth + 1);
157
+ serialized.cause = typeof serializedCause === "string" ? serializedCause : serializedCause;
158
+ } else {
159
+ serialized.cause = error.cause;
160
+ }
161
+ }
162
+ for (const key in error) {
163
+ if (Object.prototype.hasOwnProperty.call(error, key)) {
164
+ if (!["name", "message", "stack", "cause"].includes(key)) {
165
+ const errorWithCustomProps = error;
166
+ serialized[key] = errorWithCustomProps[key];
167
+ }
168
+ }
169
+ }
170
+ if (error.name === "AggregateError" && "errors" in error) {
171
+ const aggregateError = error;
172
+ if (Array.isArray(aggregateError.errors)) {
173
+ serialized.errors = aggregateError.errors.map((e) => {
174
+ if (e instanceof Error) {
175
+ const serializedErr = this.serializeError(e, config, currentDepth + 1);
176
+ return typeof serializedErr === "string" ? { name: "Error", message: serializedErr } : serializedErr;
177
+ }
178
+ return e;
179
+ });
180
+ }
181
+ }
182
+ return serialized;
183
+ }
9
184
  /**
10
185
  * Format base64 string for logging
11
186
  */
@@ -31,8 +206,11 @@ var Formatter = class {
31
206
  /**
32
207
  * Helper to serialize Blob and File objects for logging
33
208
  */
34
- static serializeSpecialObjects(obj, maxDepth = 10, currentDepth = 0) {
209
+ static serializeSpecialObjects(obj, maxDepth = 10, currentDepth = 0, filterOptions, currentPath = []) {
35
210
  if (currentDepth > maxDepth) return "[Max Depth Reached]";
211
+ if (obj instanceof Error) {
212
+ return this.serializeError(obj, filterOptions?.errorConfig, 0);
213
+ }
36
214
  if (typeof obj === "string" && isBase64(obj)) {
37
215
  return this.formatBase64String(obj);
38
216
  }
@@ -51,13 +229,62 @@ var Formatter = class {
51
229
  };
52
230
  }
53
231
  if (Array.isArray(obj)) {
54
- return obj.map((item) => this.serializeSpecialObjects(item, maxDepth, currentDepth + 1));
232
+ return obj.map(
233
+ (item, index) => this.serializeSpecialObjects(
234
+ item,
235
+ maxDepth,
236
+ currentDepth + 1,
237
+ filterOptions,
238
+ [...currentPath, `[${index}]`]
239
+ )
240
+ );
55
241
  }
56
242
  if (obj !== null && typeof obj === "object") {
57
243
  const serialized = {};
58
244
  for (const key in obj) {
59
245
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
60
- serialized[key] = this.serializeSpecialObjects(obj[key], maxDepth, currentDepth + 1);
246
+ const value = obj[key];
247
+ const newPath = [...currentPath, key];
248
+ const { include, filtered } = this.shouldIncludeField(
249
+ key,
250
+ value,
251
+ currentPath,
252
+ filterOptions
253
+ );
254
+ if (!include) {
255
+ continue;
256
+ }
257
+ let isGloballyFiltered = false;
258
+ if (filterOptions?.filterFields && filterOptions.filterFields.length > 0) {
259
+ isGloballyFiltered = this.shouldFilterField(key, value, currentPath, filterOptions.filterFields);
260
+ }
261
+ if (isGloballyFiltered) {
262
+ const filteredValue = this.getFilteredValue(
263
+ value,
264
+ filterOptions?.globalFilterMode,
265
+ filterOptions?.globalFilterReplacement
266
+ );
267
+ if (filteredValue !== void 0) {
268
+ serialized[key] = filteredValue;
269
+ }
270
+ } else if (filtered) {
271
+ const filteredValue = this.getFilteredValue(
272
+ value,
273
+ filterOptions?.filterMode,
274
+ filterOptions?.filterReplacement
275
+ );
276
+ if (filteredValue !== void 0) {
277
+ serialized[key] = filteredValue;
278
+ }
279
+ } else {
280
+ serialized[key] = this.serializeSpecialObjects(
281
+ value,
282
+ maxDepth,
283
+ currentDepth + 1,
284
+ filterOptions,
285
+ newPath
286
+ );
287
+ }
61
288
  }
62
289
  }
63
290
  return serialized;
@@ -76,10 +303,23 @@ var Formatter = class {
76
303
  const isArray = Array.isArray(arg);
77
304
  if (isObject || isArray) {
78
305
  const maxDepth = options?.maxDepth ?? 5;
79
- const serialized = this.serializeSpecialObjects(arg, maxDepth);
306
+ const serialized = this.serializeSpecialObjects(
307
+ arg,
308
+ maxDepth,
309
+ 0,
310
+ {
311
+ pick: options?.pick,
312
+ omit: options?.omit,
313
+ filterFields: options?.filterFields,
314
+ filterMode: options?.filterMode,
315
+ filterReplacement: options?.filterReplacement,
316
+ globalFilterMode: options?.globalFilterMode,
317
+ globalFilterReplacement: options?.globalFilterReplacement,
318
+ errorConfig: options?.errorConfig
319
+ }
320
+ );
80
321
  const formatted = format(serialized, {
81
- ...options,
82
- indent: options?.indent ?? 2,
322
+ indent: 2,
83
323
  maxDepth,
84
324
  // Remove Object and Array labels
85
325
  printFunctionName: false,
@@ -108,6 +348,10 @@ var LOG_COLORS = {
108
348
  none: "#fff"
109
349
  };
110
350
  var DEFAULT_NAMESPACE = "app";
351
+ var DEFAULT_DEDUP_CONFIG = {
352
+ enabled: false,
353
+ flushInterval: 100
354
+ };
111
355
 
112
356
  // src/styler.ts
113
357
  function getColorForLevel(level) {
@@ -214,6 +458,41 @@ var NamespaceMatcher = class {
214
458
  var namespaceMatcher = new NamespaceMatcher();
215
459
  var namespace_matcher_default = namespaceMatcher;
216
460
 
461
+ // src/dedup-hasher.ts
462
+ var DedupHasher = class {
463
+ /**
464
+ * Default hash function - simple string concatenation
465
+ * For better performance, we use a simple approach rather than crypto hashing
466
+ */
467
+ static generateKey(level, message, args) {
468
+ const argsString = this.serializeArgs(args);
469
+ return `${level}:${message}:${argsString}`;
470
+ }
471
+ /**
472
+ * Serialize arguments to a consistent string representation
473
+ */
474
+ static serializeArgs(args) {
475
+ if (args.length === 0) return "";
476
+ try {
477
+ return JSON.stringify(args, (key, value) => {
478
+ if (typeof value === "function") {
479
+ return "[Function]";
480
+ }
481
+ if (typeof value === "symbol") {
482
+ return value.toString();
483
+ }
484
+ if (value === void 0) {
485
+ return "[undefined]";
486
+ }
487
+ return value;
488
+ });
489
+ } catch (error) {
490
+ return String(args);
491
+ }
492
+ }
493
+ };
494
+ var dedup_hasher_default = DedupHasher;
495
+
217
496
  // src/logger.ts
218
497
  var globalConfig = {};
219
498
  function configure(options) {
@@ -222,9 +501,22 @@ function configure(options) {
222
501
  var Logger = class {
223
502
  constructor(options = {}) {
224
503
  this._level = "debug";
504
+ this._dedupBuffer = /* @__PURE__ */ new Map();
505
+ // Temporary filter options that reset after each log
506
+ this._tempFilterOptions = {};
507
+ // Custom context for conditional logging
508
+ this._context = {};
509
+ // Temporary conditions that reset after each log
510
+ this._tempConditions = [];
225
511
  this._namespace = options.namespace ?? DEFAULT_NAMESPACE;
226
512
  this._level = options.level ?? globalConfig.level ?? "debug";
227
513
  this._maxDepth = options.maxDepth ?? globalConfig.maxDepth ?? 10;
514
+ this._context = options.context ?? {};
515
+ this._dedupConfig = {
516
+ ...DEFAULT_DEDUP_CONFIG,
517
+ ...globalConfig.dedup || {},
518
+ ...options.dedup || {}
519
+ };
228
520
  }
229
521
  debug(message, ...args) {
230
522
  this.log("debug", message, ...args);
@@ -242,13 +534,134 @@ var Logger = class {
242
534
  this.log("error", message, ...args);
243
535
  return this;
244
536
  }
537
+ /**
538
+ * Assert that a condition is truthy. Logs an error if the condition is falsy.
539
+ * Similar to console.assert() but integrates with plslog's features.
540
+ * Uses JavaScript truthiness - falsy values: false, 0, '', null, undefined, NaN
541
+ *
542
+ * @param condition - The condition to assert (uses JavaScript truthiness)
543
+ * @param message - Error message to log if assertion fails
544
+ * @param args - Additional arguments to log
545
+ * @returns this for chaining
546
+ *
547
+ * @example
548
+ * logger.assert(user !== null, 'User should not be null', user);
549
+ * logger.assert(count > 0, 'Count must be positive', { count });
550
+ * logger.assert(data, 'Data is required'); // Checks if data is truthy
551
+ */
552
+ assert(condition, message, ...args) {
553
+ if (!condition) {
554
+ this.log("error", message, ...args);
555
+ }
556
+ return this;
557
+ }
245
558
  setLevel(level) {
246
559
  this._level = level;
247
560
  }
561
+ /**
562
+ * Set the namespace for this logger instance
563
+ * Returns this for method chaining
564
+ *
565
+ * @param namespace - The namespace string (e.g., 'service:api', 'component')
566
+ * @returns this for chaining
567
+ *
568
+ * @example
569
+ * const log = logger().namespace('service:api');
570
+ * log.debug('Message'); // Logs with namespace 'service:api'
571
+ *
572
+ * // Can also be called after instantiation
573
+ * const log2 = logger();
574
+ * log2.namespace('component').debug('Message');
575
+ */
576
+ namespace(namespace) {
577
+ this._namespace = namespace;
578
+ return this;
579
+ }
248
580
  maxDepth(maxDepth) {
249
581
  this._maxDepth = maxDepth;
250
582
  return this;
251
583
  }
584
+ /**
585
+ * Pick only specific fields to log (whitelist approach)
586
+ * Supports dot notation for nested fields: 'user.name', 'user.email'
587
+ * Supports wildcards: 'user.*' matches all fields under user
588
+ */
589
+ pick(fields) {
590
+ this._tempFilterOptions.pick = fields;
591
+ return this;
592
+ }
593
+ /**
594
+ * Omit specific fields from logging (blacklist approach)
595
+ * Supports strings, regex patterns, and predicate functions
596
+ */
597
+ omit(fields) {
598
+ this._tempFilterOptions.omit = fields;
599
+ return this;
600
+ }
601
+ /**
602
+ * Set filter mode for per-log filtering
603
+ * - 'redact': Replace with custom string (default: '***REDACTED***')
604
+ * - 'hide': Remove field completely
605
+ * - 'type': Show type info like '[string]', '[Array(3)]'
606
+ * - 'length': Show length info like '[8 chars]', '[3 items]'
607
+ */
608
+ filterMode(mode, replacement) {
609
+ this._tempFilterOptions.filterMode = mode;
610
+ if (replacement !== void 0) {
611
+ this._tempFilterOptions.filterReplacement = replacement;
612
+ }
613
+ return this;
614
+ }
615
+ once(enabled = true) {
616
+ this._dedupConfig.enabled = enabled;
617
+ return this;
618
+ }
619
+ flushDedup() {
620
+ const keys = Array.from(this._dedupBuffer.keys());
621
+ keys.forEach((key) => this.flushDedupEntry(key));
622
+ return this;
623
+ }
624
+ /**
625
+ * Execute log only if condition is true
626
+ * Supports boolean values or predicate functions
627
+ * Multiple when() calls are AND-ed together
628
+ *
629
+ * @example
630
+ * // Boolean condition
631
+ * logger.when(isDev).debug('Dev only');
632
+ *
633
+ * // Predicate with context
634
+ * logger.when(ctx => ctx.namespace === 'auth').debug('Auth log');
635
+ *
636
+ * // Chaining (both must be true)
637
+ * logger
638
+ * .when(ctx => ctx.level === 'debug')
639
+ * .when(isDevMode)
640
+ * .debug('Conditional');
641
+ */
642
+ when(condition) {
643
+ this._tempConditions.push(condition);
644
+ return this;
645
+ }
646
+ /**
647
+ * Execute log only if condition is false (inverse of when)
648
+ * Supports boolean values or predicate functions
649
+ *
650
+ * @example
651
+ * // Boolean condition
652
+ * logger.unless(isProd).debug('Not in production');
653
+ *
654
+ * // Predicate with context
655
+ * logger.unless(ctx => ctx.level === 'error').info('Non-error log');
656
+ */
657
+ unless(condition) {
658
+ if (typeof condition === "function") {
659
+ this._tempConditions.push((ctx) => !condition(ctx));
660
+ } else {
661
+ this._tempConditions.push(!condition);
662
+ }
663
+ return this;
664
+ }
252
665
  getLevelPriority(level) {
253
666
  const priorities = {
254
667
  debug: 0,
@@ -260,6 +673,21 @@ var Logger = class {
260
673
  return priorities[level];
261
674
  }
262
675
  log(level, message, ...args) {
676
+ if (this._tempConditions.length > 0) {
677
+ const ctx = {
678
+ namespace: this._namespace,
679
+ level,
680
+ ...this._context
681
+ };
682
+ const conditionsMet = this._tempConditions.every(
683
+ (condition) => typeof condition === "function" ? condition(ctx) : condition
684
+ );
685
+ this._tempConditions = [];
686
+ if (!conditionsMet) {
687
+ this._tempFilterOptions = {};
688
+ return;
689
+ }
690
+ }
263
691
  if (!namespace_matcher_default.matches(this._namespace, globalConfig.namespaces)) {
264
692
  return;
265
693
  }
@@ -270,8 +698,80 @@ var Logger = class {
270
698
  } else if (this.getLevelPriority(this._level) > this.getLevelPriority(level)) {
271
699
  return;
272
700
  }
701
+ const filterOptions = { ...this._tempFilterOptions };
702
+ this._tempFilterOptions = {};
703
+ if (this._dedupConfig.enabled) {
704
+ this.logWithDedup(level, message, filterOptions, ...args);
705
+ } else {
706
+ this.logImmediate(level, message, filterOptions, ...args);
707
+ }
708
+ }
709
+ logWithDedup(level, message, filterOptions, ...args) {
710
+ const key = dedup_hasher_default.generateKey(level, message, args);
711
+ const existing = this._dedupBuffer.get(key);
712
+ const now = Date.now();
713
+ if (existing) {
714
+ existing.count++;
715
+ existing.lastTimestamp = now;
716
+ existing.args = args;
717
+ existing.filterOptions = filterOptions;
718
+ if (existing.flushTimeoutId !== void 0) {
719
+ clearTimeout(existing.flushTimeoutId);
720
+ }
721
+ existing.flushTimeoutId = setTimeout(() => {
722
+ this.flushDedupEntry(key);
723
+ }, this._dedupConfig.flushInterval);
724
+ } else {
725
+ const entry = {
726
+ key,
727
+ level,
728
+ message,
729
+ args,
730
+ count: 1,
731
+ firstTimestamp: now,
732
+ lastTimestamp: now,
733
+ filterOptions
734
+ };
735
+ this._dedupBuffer.set(key, entry);
736
+ entry.flushTimeoutId = setTimeout(() => {
737
+ this.flushDedupEntry(key);
738
+ }, this._dedupConfig.flushInterval);
739
+ }
740
+ }
741
+ flushDedupEntry(key) {
742
+ const entry = this._dedupBuffer.get(key);
743
+ if (!entry) return;
744
+ let displayMessage = entry.message;
745
+ if (entry.count > 1) {
746
+ const firstTime = this.formatTimestamp(entry.firstTimestamp);
747
+ const lastTime = this.formatTimestamp(entry.lastTimestamp);
748
+ displayMessage = `${entry.message} (x${entry.count}, ${firstTime} \u2192 ${lastTime})`;
749
+ }
750
+ this.logImmediate(entry.level, displayMessage, entry.filterOptions || {}, ...entry.args);
751
+ this._dedupBuffer.delete(key);
752
+ }
753
+ formatTimestamp(timestamp) {
754
+ const date = new Date(timestamp);
755
+ const hours = String(date.getHours()).padStart(2, "0");
756
+ const minutes = String(date.getMinutes()).padStart(2, "0");
757
+ const seconds = String(date.getSeconds()).padStart(2, "0");
758
+ const milliseconds = String(date.getMilliseconds()).padStart(3, "0");
759
+ return `${hours}:${minutes}:${seconds}.${milliseconds}`;
760
+ }
761
+ logImmediate(level, message, filterOptions, ...args) {
273
762
  const formattedObject = Formatter.formatObj(args, {
274
- maxDepth: this._maxDepth
763
+ maxDepth: this._maxDepth,
764
+ // Global filter fields (always applied for security)
765
+ filterFields: globalConfig.filterFields,
766
+ globalFilterMode: globalConfig.filterMode,
767
+ globalFilterReplacement: globalConfig.filterReplacement,
768
+ // Per-log pick/omit (contextual filtering)
769
+ pick: filterOptions.pick,
770
+ omit: filterOptions.omit,
771
+ filterMode: filterOptions.filterMode,
772
+ filterReplacement: filterOptions.filterReplacement,
773
+ // Error serialization config
774
+ errorConfig: globalConfig.errorSerialization
275
775
  });
276
776
  const formattedMessage = formattedObject ? `${message} ${formattedObject}` : message;
277
777
  const { logMessage, logStyles } = styler_default.style({
@@ -299,7 +799,8 @@ var Logger = class {
299
799
  }
300
800
  };
301
801
  var plslog = ((options = {}) => {
302
- return new Logger(options);
802
+ const normalizedOptions = typeof options === "string" ? { namespace: options } : options;
803
+ return new Logger(normalizedOptions);
303
804
  });
304
805
  plslog.configure = configure;
305
806
  var logger_default = plslog;