logquill 0.2.0 → 0.3.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
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'crypto';
1
2
  import { gzipSync } from 'zlib';
2
3
  import { mkdirSync, openSync, writeSync, fstatSync, closeSync, existsSync, unlinkSync, renameSync } from 'fs';
3
4
  import { dirname } from 'path';
@@ -58,6 +59,17 @@ var JSONFormatter = class {
58
59
  }
59
60
  };
60
61
 
62
+ // src/core/plugin.ts
63
+ var FunctionPlugin = class {
64
+ func;
65
+ constructor(func) {
66
+ this.func = func;
67
+ }
68
+ beforeLog(record) {
69
+ return this.func(record);
70
+ }
71
+ };
72
+
61
73
  // src/plugins/context-plugin.ts
62
74
  var ContextPlugin = class {
63
75
  context;
@@ -87,19 +99,409 @@ var RedactPlugin = class {
87
99
  }
88
100
  };
89
101
 
102
+ // src/plugins/pii-redact-plugin.ts
103
+ var DEFAULT_PII_PATTERNS = {
104
+ email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
105
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
106
+ creditCard: /\b(?:\d[ -]?){13,16}\b/g,
107
+ phone: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
108
+ };
109
+ var MAX_DEPTH = 50;
110
+ var PIIRedactPlugin = class {
111
+ patterns;
112
+ replacement;
113
+ constructor(options = {}) {
114
+ this.patterns = options.patterns ?? DEFAULT_PII_PATTERNS;
115
+ this.replacement = options.replacement ?? "***";
116
+ }
117
+ beforeLog(record) {
118
+ return { ...record, meta: this.redactValue(record.meta, /* @__PURE__ */ new Set(), 0) };
119
+ }
120
+ redactValue(value, seen, depth) {
121
+ if (depth > MAX_DEPTH) {
122
+ return value;
123
+ }
124
+ if (typeof value === "string") {
125
+ return this.redactText(value);
126
+ }
127
+ if (Array.isArray(value)) {
128
+ if (seen.has(value)) {
129
+ return value;
130
+ }
131
+ const nextSeen = new Set(seen).add(value);
132
+ return value.map((entry) => this.redactValue(entry, nextSeen, depth + 1));
133
+ }
134
+ if (value !== null && typeof value === "object") {
135
+ if (seen.has(value)) {
136
+ return value;
137
+ }
138
+ const nextSeen = new Set(seen).add(value);
139
+ const result = {};
140
+ for (const [key, entryValue] of Object.entries(value)) {
141
+ result[key] = this.redactValue(entryValue, nextSeen, depth + 1);
142
+ }
143
+ return result;
144
+ }
145
+ return value;
146
+ }
147
+ redactText(text) {
148
+ let redacted = text;
149
+ for (const pattern of Object.values(this.patterns)) {
150
+ const global = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
151
+ redacted = redacted.replace(global, this.replacement);
152
+ }
153
+ return redacted;
154
+ }
155
+ };
156
+
90
157
  // src/plugins/sampling-plugin.ts
91
158
  var SamplingPlugin = class {
92
159
  rate;
160
+ traceKey;
161
+ elevateAt;
162
+ transports;
163
+ maxBufferedRecords;
164
+ maxTraces;
93
165
  rng;
166
+ buffer = /* @__PURE__ */ new Map();
167
+ bufferedCount = 0;
168
+ elevated = /* @__PURE__ */ new Set();
94
169
  constructor(rate, options = {}) {
95
170
  if (rate < 0 || rate > 1) {
96
171
  throw new Error(`rate must be between 0 and 1, got ${String(rate)}`);
97
172
  }
98
173
  this.rate = rate;
99
174
  this.rng = options.rng ?? Math.random;
175
+ this.traceKey = options.traceKey ?? "traceId";
176
+ this.elevateAt = parseLevel(options.elevateAt ?? 40 /* ERROR */);
177
+ this.transports = options.transports;
178
+ this.maxBufferedRecords = options.maxBufferedRecords ?? 1e3;
179
+ this.maxTraces = options.maxTraces ?? 200;
100
180
  }
101
181
  beforeLog(record) {
102
- return this.rng() < this.rate ? record : null;
182
+ const transports = this.transports;
183
+ if (transports === void 0) {
184
+ return this.rng() < this.rate ? record : null;
185
+ }
186
+ const traceId = record.meta[this.traceKey];
187
+ if (traceId !== void 0 && this.elevated.has(traceId)) {
188
+ return record;
189
+ }
190
+ const keep = this.rng() < this.rate;
191
+ const reachedElevateLevel = parseLevel(record.level) >= this.elevateAt;
192
+ if (traceId !== void 0 && reachedElevateLevel) {
193
+ this.elevate(traceId, transports);
194
+ return record;
195
+ }
196
+ if (keep) {
197
+ return record;
198
+ }
199
+ if (traceId !== void 0) {
200
+ this.bufferRecord(traceId, record);
201
+ }
202
+ return null;
203
+ }
204
+ elevate(traceId, transports) {
205
+ this.elevated.add(traceId);
206
+ const buffered = this.buffer.get(traceId) ?? [];
207
+ this.buffer.delete(traceId);
208
+ this.bufferedCount -= buffered.length;
209
+ for (const bufferedRecord of buffered) {
210
+ for (const transport of transports) {
211
+ transport.write(transport.format(bufferedRecord), bufferedRecord);
212
+ }
213
+ }
214
+ }
215
+ bufferRecord(traceId, record) {
216
+ let records = this.buffer.get(traceId);
217
+ if (records) {
218
+ this.buffer.delete(traceId);
219
+ this.buffer.set(traceId, records);
220
+ } else {
221
+ if (this.buffer.size >= this.maxTraces) {
222
+ this.evictOldestTrace();
223
+ }
224
+ records = [];
225
+ this.buffer.set(traceId, records);
226
+ }
227
+ records.push(record);
228
+ this.bufferedCount += 1;
229
+ while (this.bufferedCount > this.maxBufferedRecords && this.buffer.size > 0) {
230
+ this.evictOldestTrace();
231
+ }
232
+ }
233
+ evictOldestTrace() {
234
+ const oldest = this.buffer.entries().next();
235
+ if (oldest.done) {
236
+ return;
237
+ }
238
+ const [oldestKey, oldestRecords] = oldest.value;
239
+ this.buffer.delete(oldestKey);
240
+ this.bufferedCount -= oldestRecords.length;
241
+ }
242
+ };
243
+ var GENESIS_HASH = "0".repeat(64);
244
+ function canonicalStringify(value) {
245
+ if (Array.isArray(value)) {
246
+ return `[${value.map((entry) => canonicalStringify(entry)).join(",")}]`;
247
+ }
248
+ if (value !== null && typeof value === "object") {
249
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
250
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalStringify(entryValue)}`).join(",")}}`;
251
+ }
252
+ if (value === void 0) {
253
+ return "null";
254
+ }
255
+ return JSON.stringify(value);
256
+ }
257
+ function computeHash(record, prevHash) {
258
+ const restMeta = Object.fromEntries(Object.entries(record.meta).filter(([key]) => key !== "hash" && key !== "prevHash"));
259
+ const payload = canonicalStringify({
260
+ timestamp: record.timestamp,
261
+ level: record.level,
262
+ logger: record.logger,
263
+ message: record.message,
264
+ meta: restMeta
265
+ });
266
+ return createHash("sha256").update(`${prevHash}${payload}`).digest("hex");
267
+ }
268
+ var TamperEvidentPlugin = class {
269
+ genesisHash;
270
+ lastHash;
271
+ constructor(options = {}) {
272
+ this.genesisHash = options.genesisHash ?? GENESIS_HASH;
273
+ this.lastHash = this.genesisHash;
274
+ }
275
+ beforeLog(record) {
276
+ const prevHash = this.lastHash;
277
+ const digest = computeHash(record, prevHash);
278
+ const next = { ...record, meta: { ...record.meta, prevHash, hash: digest } };
279
+ this.lastHash = digest;
280
+ return next;
281
+ }
282
+ /**
283
+ * Returns `true` iff every record's hash matches its content plus the
284
+ * previous record's hash, in the given order. Returns `false` at the
285
+ * first break in the chain (an edited, removed, or reordered record).
286
+ */
287
+ static verifyChain(records, options = {}) {
288
+ let prevHash = options.genesisHash ?? GENESIS_HASH;
289
+ for (const record of records) {
290
+ const storedHash = record.meta.hash;
291
+ const storedPrevHash = record.meta.prevHash;
292
+ if (typeof storedHash !== "string" || storedPrevHash !== prevHash) {
293
+ return false;
294
+ }
295
+ if (computeHash(record, prevHash) !== storedHash) {
296
+ return false;
297
+ }
298
+ prevHash = storedHash;
299
+ }
300
+ return true;
301
+ }
302
+ };
303
+
304
+ // src/plugins/alerting-plugin.ts
305
+ function defaultDedupeKey(record) {
306
+ return `${record.level}:${record.logger}:${record.message}`;
307
+ }
308
+ var AlertingPlugin = class {
309
+ threshold;
310
+ dedupeWindowMs;
311
+ maxTrackedKeys;
312
+ dedupeKeyFn;
313
+ windows = /* @__PURE__ */ new Map();
314
+ constructor(options = {}) {
315
+ this.threshold = parseLevel(options.threshold ?? 40 /* ERROR */);
316
+ this.dedupeWindowMs = options.dedupeWindowMs ?? 3e5;
317
+ this.dedupeKeyFn = options.dedupeKey ?? defaultDedupeKey;
318
+ this.maxTrackedKeys = options.maxTrackedKeys ?? 500;
319
+ }
320
+ afterLog(record) {
321
+ if (parseLevel(record.level) < this.threshold) {
322
+ return;
323
+ }
324
+ const key = this.dedupeKeyFn(record);
325
+ const existing = this.windows.get(key);
326
+ if (existing) {
327
+ existing.count += 1;
328
+ return;
329
+ }
330
+ if (this.windows.size >= this.maxTrackedKeys) {
331
+ return;
332
+ }
333
+ const timer = setTimeout(() => {
334
+ this.flush(key);
335
+ }, this.dedupeWindowMs);
336
+ timer.unref();
337
+ this.windows.set(key, { record, count: 1, timer });
338
+ this.safeSend(record, 1);
339
+ }
340
+ flush(key) {
341
+ const window = this.windows.get(key);
342
+ this.windows.delete(key);
343
+ if (!window || window.count <= 1) {
344
+ return;
345
+ }
346
+ this.safeSend(window.record, window.count);
347
+ }
348
+ safeSend(record, occurrences) {
349
+ Promise.resolve().then(() => this.sendAlert(record, occurrences)).catch((error) => {
350
+ try {
351
+ this.onError?.(error, record);
352
+ } catch {
353
+ }
354
+ });
355
+ }
356
+ /** Cancel any pending dedupe-window timers. Call on logger shutdown. */
357
+ close() {
358
+ const windows = [...this.windows.values()];
359
+ this.windows.clear();
360
+ for (const window of windows) {
361
+ clearTimeout(window.timer);
362
+ }
363
+ }
364
+ };
365
+
366
+ // src/plugins/slack-alert-plugin.ts
367
+ async function fetchSlackSender(webhookUrl, body) {
368
+ const response = await fetch(webhookUrl, {
369
+ method: "POST",
370
+ headers: { "Content-Type": "application/json" },
371
+ body
372
+ });
373
+ if (!response.ok) {
374
+ throw new Error(
375
+ `SlackAlertPlugin: webhook returned HTTP ${String(response.status)} \u2014 check the webhook URL is still valid in Slack's app config`
376
+ );
377
+ }
378
+ }
379
+ function formatMessage(record, occurrences) {
380
+ const suffix = occurrences > 1 ? ` (x${String(occurrences)})` : "";
381
+ return `[${record.level}] ${record.logger}: ${record.message}${suffix}`;
382
+ }
383
+ var SlackAlertPlugin = class extends AlertingPlugin {
384
+ webhookUrl;
385
+ sender;
386
+ constructor(webhookUrl, options = {}) {
387
+ super(options);
388
+ this.webhookUrl = webhookUrl;
389
+ this.sender = options.sender ?? fetchSlackSender;
390
+ }
391
+ async sendAlert(record, occurrences) {
392
+ const body = JSON.stringify({ text: formatMessage(record, occurrences) });
393
+ await this.sender(this.webhookUrl, body);
394
+ }
395
+ };
396
+
397
+ // src/plugins/pagerduty-alert-plugin.ts
398
+ var ENDPOINT = "https://events.pagerduty.com/v2/enqueue";
399
+ var SEVERITY = { ERROR: "error", FATAL: "critical" };
400
+ async function fetchPagerDutySender(body) {
401
+ const response = await fetch(ENDPOINT, {
402
+ method: "POST",
403
+ headers: { "Content-Type": "application/json" },
404
+ body
405
+ });
406
+ if (!response.ok) {
407
+ throw new Error(
408
+ `PagerDutyAlertPlugin: Events API returned HTTP ${String(response.status)} \u2014 check the routing key is a valid Events API v2 integration key`
409
+ );
410
+ }
411
+ }
412
+ var PagerDutyAlertPlugin = class extends AlertingPlugin {
413
+ routingKey;
414
+ sender;
415
+ constructor(routingKey, options = {}) {
416
+ super(options);
417
+ this.routingKey = routingKey;
418
+ this.sender = options.sender ?? fetchPagerDutySender;
419
+ }
420
+ async sendAlert(record, occurrences) {
421
+ let summary = `${record.logger}: ${record.message}`;
422
+ if (occurrences > 1) {
423
+ summary += ` (x${String(occurrences)})`;
424
+ }
425
+ const body = JSON.stringify({
426
+ routing_key: this.routingKey,
427
+ event_action: "trigger",
428
+ payload: {
429
+ summary,
430
+ severity: SEVERITY[record.level] ?? "error",
431
+ source: record.logger,
432
+ timestamp: record.timestamp,
433
+ custom_details: { occurrences, ...record.meta }
434
+ }
435
+ });
436
+ await this.sender(body);
437
+ }
438
+ };
439
+
440
+ // src/plugins/email-alert-plugin.ts
441
+ var EmailAlertPlugin = class extends AlertingPlugin {
442
+ smtpHost;
443
+ smtpPort;
444
+ fromAddr;
445
+ toAddrs;
446
+ username;
447
+ password;
448
+ useTls;
449
+ injectedSender;
450
+ transporter;
451
+ constructor(options) {
452
+ super(options);
453
+ this.smtpHost = options.smtpHost;
454
+ this.smtpPort = options.smtpPort;
455
+ this.fromAddr = options.fromAddr;
456
+ this.toAddrs = options.toAddrs;
457
+ this.username = options.username;
458
+ this.password = options.password;
459
+ this.useTls = options.useTls ?? true;
460
+ this.injectedSender = options.sender;
461
+ }
462
+ async sendAlert(record, occurrences) {
463
+ let subject = `[${record.level}] ${record.logger}`;
464
+ if (occurrences > 1) {
465
+ subject += ` (x${String(occurrences)})`;
466
+ }
467
+ const text = [
468
+ record.message,
469
+ "",
470
+ `occurrences: ${String(occurrences)}`,
471
+ `timestamp: ${record.timestamp}`,
472
+ `meta: ${JSON.stringify(record.meta)}`
473
+ ].join("\n");
474
+ const message = { from: this.fromAddr, to: this.toAddrs, subject, text };
475
+ if (this.injectedSender) {
476
+ await this.injectedSender(message);
477
+ return;
478
+ }
479
+ const transporter = this.transporter ?? await this.importTransporter();
480
+ await transporter.sendMail({ from: message.from, to: message.to.join(", "), subject: message.subject, text: message.text });
481
+ }
482
+ async importTransporter() {
483
+ let createTransport;
484
+ try {
485
+ const moduleName = "nodemailer";
486
+ const mod = await import(moduleName);
487
+ const resolved = mod.default?.createTransport ?? mod.createTransport;
488
+ if (!resolved) {
489
+ throw new Error("no createTransport export found");
490
+ }
491
+ createTransport = resolved;
492
+ } catch {
493
+ throw new Error(
494
+ "EmailAlertPlugin: install `nodemailer` to use this plugin without providing a `sender` \u2014 `npm install nodemailer`"
495
+ );
496
+ }
497
+ this.transporter = createTransport({
498
+ host: this.smtpHost,
499
+ port: this.smtpPort,
500
+ secure: false,
501
+ requireTLS: this.useTls,
502
+ auth: this.username && this.password ? { user: this.username, pass: this.password } : void 0
503
+ });
504
+ return this.transporter;
103
505
  }
104
506
  };
105
507
 
@@ -1318,7 +1720,10 @@ var Logger = class _Logger {
1318
1720
  this.name = name;
1319
1721
  this.currentLevel = parseLevel(options.level ?? 20 /* INFO */);
1320
1722
  this.transports = options.transports ? [...options.transports] : [];
1321
- this.plugins = options.plugins ? [...options.plugins] : [];
1723
+ this.plugins = [];
1724
+ for (const plugin of options.plugins ?? []) {
1725
+ this.use(plugin);
1726
+ }
1322
1727
  this.baseMeta = options.meta ? { ...options.meta } : {};
1323
1728
  }
1324
1729
  get level() {
@@ -1327,9 +1732,14 @@ var Logger = class _Logger {
1327
1732
  setLevel(level) {
1328
1733
  this.currentLevel = parseLevel(level);
1329
1734
  }
1330
- /** Register a plugin. Returns `this` so calls can be chained. */
1735
+ /**
1736
+ * Register a plugin, or a plain `beforeLog`-style function. A function is
1737
+ * wrapped internally as an anonymous `Plugin` (`FunctionPlugin`) — the
1738
+ * same middleware ergonomics as Express/Koa, without needing to read the
1739
+ * `Plugin` interface first. Returns `this` so calls can be chained.
1740
+ */
1331
1741
  use(plugin) {
1332
- this.plugins.push(plugin);
1742
+ this.plugins.push(typeof plugin === "function" ? new FunctionPlugin(plugin) : plugin);
1333
1743
  return this;
1334
1744
  }
1335
1745
  /** Close every attached transport. Call on shutdown to flush buffered writes. */
@@ -1409,8 +1819,8 @@ var Logger = class _Logger {
1409
1819
  };
1410
1820
 
1411
1821
  // src/index.ts
1412
- var VERSION = "0.2.0";
1822
+ var VERSION = "0.3.0";
1413
1823
 
1414
- export { AppInsightsTransport, BaseQueueTransport, BaseSQLTransport, BatchingTransport, CloudLoggingTransport, CloudWatchTransport, CollectingTransport, ConsoleTransport, ContextPlugin, DEFAULT_REDACTED_KEYS, DatadogTransport, DynamoDBTransport, ElasticsearchTransport, FileTransport, HTTPTransport, JSONFormatter, KafkaTransport, Level, Logger, MongoDBTransport, MySQLTransport, NewRelicTransport, PostgresTransport, PubSubTransport, RabbitMQTransport, RedactPlugin, RedisTransport, SQLiteTransport, SQSTransport, SamplingPlugin, Transport, VERSION, createRecord, levelName, parseLevel, utcTimestamp };
1824
+ export { AlertingPlugin, AppInsightsTransport, BaseQueueTransport, BaseSQLTransport, BatchingTransport, CloudLoggingTransport, CloudWatchTransport, CollectingTransport, ConsoleTransport, ContextPlugin, DEFAULT_PII_PATTERNS, DEFAULT_REDACTED_KEYS, DatadogTransport, DynamoDBTransport, ElasticsearchTransport, EmailAlertPlugin, FileTransport, FunctionPlugin, GENESIS_HASH, HTTPTransport, JSONFormatter, KafkaTransport, Level, Logger, MongoDBTransport, MySQLTransport, NewRelicTransport, PIIRedactPlugin, PagerDutyAlertPlugin, PostgresTransport, PubSubTransport, RabbitMQTransport, RedactPlugin, RedisTransport, SQLiteTransport, SQSTransport, SamplingPlugin, SlackAlertPlugin, TamperEvidentPlugin, Transport, VERSION, createRecord, levelName, parseLevel, utcTimestamp };
1415
1825
  //# sourceMappingURL=index.mjs.map
1416
1826
  //# sourceMappingURL=index.mjs.map