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