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/README.md CHANGED
@@ -10,23 +10,41 @@ JSON log shape with its Python sibling, [`logquill`](https://pypi.org/project/lo
10
10
  (repo: `logquill-python`).
11
11
 
12
12
  Status: pre-release, under active development. The core `Logger`, level
13
- filtering, the plugin pipeline (with `ContextPlugin`/`RedactPlugin`/
14
- `SamplingPlugin` built in), `JSONFormatter`, and the built-in transports are
15
- implemented; non-blocking async dispatch is not yet — see `CHANGELOG.md`
16
- for what's landed so far.
13
+ filtering, a full plugin pipeline (context/redaction/PII/tamper-evidence/
14
+ sampling/alerting), `JSONFormatter`, and a broad transport catalog —
15
+ console/file/HTTP, SQL, NoSQL, message queues, and cloud-native log
16
+ platforms are implemented; non-blocking async dispatch is not yet — see
17
+ `CHANGELOG.md` for what's landed so far.
17
18
 
18
19
  ## Features
19
20
 
20
21
  - **Structured by default** — every call carries a `meta` object, not just a message string
21
22
  - **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on PyPI](https://pypi.org/project/logquill/)
22
23
  - **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> string` for your own
23
- - **Pluggable transports** — `ConsoleTransport` (colorized, respects `NO_COLOR`, isomorphic), `FileTransport` (rotation), `HTTPTransport` (batched, `fetch`-based); write your own by subclassing `Transport`; `CollectingTransport` ships as an in-memory sink, handy for tests
24
- - **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; `beforeLog`/`afterLog`/`onError` hooks; a throwing plugin can't crash logging
24
+ - **A transport for wherever your logs need to go** — console, file, and HTTP out of the box, plus SQL (SQLite/Postgres/MySQL), NoSQL (MongoDB/DynamoDB/Redis), message queues (Kafka/RabbitMQ/SQS/Pub-Sub), and cloud-native platforms (CloudWatch/Cloud Logging/App Insights/Datadog/Elasticsearch/New Relic) see [Transports](#transports). Every backend driver is an **optional peer dependency**: install only the one you use, or inject a pre-built client. Write your own by subclassing `Transport`; `CollectingTransport` ships as an in-memory sink, handy for tests
25
+ - **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `PIIRedactPlugin`, tail-based `SamplingPlugin`, `TamperEvidentPlugin`, and `AlertingPlugin` (`SlackAlertPlugin`/`PagerDutyAlertPlugin`/`EmailAlertPlugin`) out of the box; `beforeLog`/`afterLog`/`onError` hooks, or just pass a plain function to `.use()`; a throwing plugin can't crash logging — see [Plugins](#plugins)
25
26
  - **Child loggers** — `.child()` inherits level, transports, and plugins, and merges its own `meta` on top
26
27
  - **Typed throughout** — TypeScript strict mode, no `any` in the public API
27
28
  - **Dual package** — works via both `require()` (CJS) and `import` (ESM) from the same published package
28
29
  - *(planned)* non-blocking async dispatch, `AsyncLocalStorage`-based context propagation — see `CHANGELOG.md`
29
30
 
31
+ ## Contents
32
+
33
+ - [Install](#install)
34
+ - [Usage](#usage)
35
+ - [Transports](#transports)
36
+ - [SQL transports](#sql-transports)
37
+ - [NoSQL transports](#nosql-transports)
38
+ - [Message queue transports](#message-queue-transports)
39
+ - [Cloud-native transports](#cloud-native-transports)
40
+ - [Plugins](#plugins)
41
+ - [Tail-based sampling](#tail-based-sampling)
42
+ - [PII redaction](#pii-redaction)
43
+ - [Tamper-evident logs](#tamper-evident-logs)
44
+ - [Alerting](#alerting)
45
+ - [Development](#development)
46
+ - [License](#license)
47
+
30
48
  ## Install
31
49
 
32
50
  ```sh
@@ -90,7 +108,36 @@ a dual build with full TypeScript types.
90
108
 
91
109
  Attach transports to a `Logger` to actually write records somewhere. Each
92
110
  record is dispatched to every attached transport synchronously
93
- (non-blocking dispatch isn't implemented yet):
111
+ (non-blocking dispatch isn't implemented yet). Every backend driver below
112
+ is an **optional peer dependency** — `npm install` only pulls in what you
113
+ actually import; nothing is installed on your behalf.
114
+
115
+ | Transport | Backend | Peer dependency | Setup |
116
+ |---|---|---|---|
117
+ | `ConsoleTransport` | stdout/stderr via `console.*` | *(none)* | zero-setup, isomorphic |
118
+ | `FileTransport` | local file, with rotation | *(none)* | zero-setup |
119
+ | `HTTPTransport` | any HTTP log endpoint | *(none, uses `fetch`)* | zero-setup |
120
+ | `SQLiteTransport` | SQLite | `better-sqlite3` | zero-setup (file or `:memory:`) |
121
+ | `PostgresTransport` | PostgreSQL | `pg` | needs a server |
122
+ | `MySQLTransport` | MySQL | `mysql2` | needs a server |
123
+ | `MongoDBTransport` | MongoDB | `mongodb` | needs a server |
124
+ | `DynamoDBTransport` | AWS DynamoDB | `@aws-sdk/client-dynamodb` | needs an AWS account |
125
+ | `RedisTransport` | Redis Streams | `redis` | needs a server |
126
+ | `KafkaTransport` | Kafka | `kafkajs` | needs a broker |
127
+ | `RabbitMQTransport` | RabbitMQ | `amqplib` | needs a broker |
128
+ | `SQSTransport` | AWS SQS | `@aws-sdk/client-sqs` | needs an AWS account |
129
+ | `PubSubTransport` | GCP Pub/Sub | `@google-cloud/pubsub` | needs a GCP account |
130
+ | `CloudWatchTransport` | AWS CloudWatch Logs | `@aws-sdk/client-cloudwatch-logs` | needs an AWS account |
131
+ | `CloudLoggingTransport` | GCP Cloud Logging | `@google-cloud/logging` | needs a GCP account |
132
+ | `AppInsightsTransport` | Azure Application Insights | `applicationinsights` | needs an Azure account |
133
+ | `DatadogTransport` | Datadog Logs | *(none, uses `fetch`)* | needs a Datadog account |
134
+ | `ElasticsearchTransport` | Elasticsearch `_bulk` API | *(none, uses `fetch`)* | needs a cluster |
135
+ | `NewRelicTransport` | New Relic Log API | *(none, uses `fetch`)* | needs a New Relic account |
136
+
137
+ Every transport also accepts an injected client/sender in place of its
138
+ default driver — the pattern every example below and every transport's own
139
+ test suite uses, so you never need a live backend just to test your
140
+ logging setup.
94
141
 
95
142
  ```ts
96
143
  import { ConsoleTransport, FileTransport, HTTPTransport, Logger } from "logquill";
@@ -366,7 +413,94 @@ logger.info("login attempt", { user_id: 42, password: "hunter2" });
366
413
  // (unless this call was one of the ~90% sampling dropped, in which case it's null)
367
414
  ```
368
415
 
369
- Write your own by implementing `Plugin`; every hook is optional.
416
+ Write your own by implementing `Plugin`; every hook is optional. `.use()`
417
+ also accepts a plain function in place of a `Plugin` — sugar for a
418
+ single-method `beforeLog` plugin, Express/Koa-style:
419
+
420
+ ```ts
421
+ logger.use((record) => {
422
+ delete record.meta.ssn;
423
+ return record; // or null to drop the record
424
+ });
425
+ ```
426
+
427
+ ### Tail-based sampling
428
+
429
+ `SamplingPlugin` can do more than flat-rate sampling: pass `transports`
430
+ and it buffers a sampled-out record under its `meta.traceId` instead of
431
+ dropping it outright. If a later record on that same trace reaches
432
+ `elevateAt` (default `ERROR`), the whole trace is flushed — every buffered
433
+ record for it, plus everything from then on — so a request that turned out
434
+ to matter still produces a complete trace, even though most of its steps
435
+ would otherwise have been sampled away.
436
+
437
+ ```ts
438
+ import { CollectingTransport, Logger, SamplingPlugin } from "logquill";
439
+
440
+ const sink = new CollectingTransport();
441
+ const logger = new Logger("app", {
442
+ transports: [sink],
443
+ plugins: [new SamplingPlugin(0.01, { transports: [sink] })], // keep 1%, but never lose an errored trace
444
+ });
445
+
446
+ logger.info("step 1", { traceId: "req-42" }); // likely dropped...
447
+ logger.info("step 2", { traceId: "req-42" }); // ...and this one too
448
+ logger.error("step 3 failed", { traceId: "req-42" }); // elevates req-42 — steps 1-3 all ship
449
+ ```
450
+
451
+ ### PII redaction
452
+
453
+ `PIIRedactPlugin` complements `RedactPlugin`'s exact-key matching with
454
+ regex-based scanning of `meta` **values** — emails, SSNs, credit-card
455
+ numbers, and phone numbers are redacted wherever they appear, recursively
456
+ through nested objects/arrays, regardless of which key holds them.
457
+
458
+ ```ts
459
+ import { Logger, PIIRedactPlugin } from "logquill";
460
+
461
+ const logger = new Logger("app", { plugins: [new PIIRedactPlugin()] });
462
+ logger.info("support ticket", { notes: "contact me at jane@example.com" });
463
+ // meta.notes: "contact me at ***"
464
+ ```
465
+
466
+ ### Tamper-evident logs
467
+
468
+ `TamperEvidentPlugin` hash-chains every record — each one's `meta.hash` is
469
+ a SHA-256 digest over its own content plus the previous record's hash — so
470
+ editing, removing, or reordering a written line breaks the chain from that
471
+ point on, detectable later with the static `verifyChain()`. Opt-in: hashing
472
+ every record has a real CPU cost.
473
+
474
+ ```ts
475
+ import { Logger, TamperEvidentPlugin } from "logquill";
476
+
477
+ const logger = new Logger("app", { plugins: [new TamperEvidentPlugin()] });
478
+ const records = [logger.info("one"), logger.info("two")];
479
+
480
+ TamperEvidentPlugin.verifyChain(records); // true
481
+ ```
482
+
483
+ ### Alerting
484
+
485
+ `AlertingPlugin` is the base for plugins that fire an external alert on
486
+ ERROR/FATAL (or any configurable `threshold`) without ever blocking the
487
+ log call that triggered it. Repeated matches within a dedupe window
488
+ collapse into a single follow-up alert reporting the total count, instead
489
+ of spamming the destination once per record.
490
+
491
+ ```ts
492
+ import { Logger, SlackAlertPlugin } from "logquill";
493
+
494
+ const logger = new Logger("app", {
495
+ plugins: [new SlackAlertPlugin("https://hooks.slack.com/services/...")],
496
+ });
497
+ logger.error("payment webhook failed", { orderId: "o-123" });
498
+ ```
499
+
500
+ `PagerDutyAlertPlugin` (Events API v2, no extra dependency) and
501
+ `EmailAlertPlugin` (SMTP via the optional `nodemailer` peer dependency, or
502
+ inject a `sender`) follow the same shape. Write your own by extending
503
+ `AlertingPlugin` and implementing `sendAlert(record, occurrences)`.
370
504
 
371
505
  ## Development
372
506
 
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var crypto = require('crypto');
3
4
  var zlib = require('zlib');
4
5
  var fs = require('fs');
5
6
  var path = require('path');
@@ -60,6 +61,17 @@ var JSONFormatter = class {
60
61
  }
61
62
  };
62
63
 
64
+ // src/core/plugin.ts
65
+ var FunctionPlugin = class {
66
+ func;
67
+ constructor(func) {
68
+ this.func = func;
69
+ }
70
+ beforeLog(record) {
71
+ return this.func(record);
72
+ }
73
+ };
74
+
63
75
  // src/plugins/context-plugin.ts
64
76
  var ContextPlugin = class {
65
77
  context;
@@ -89,19 +101,409 @@ var RedactPlugin = class {
89
101
  }
90
102
  };
91
103
 
104
+ // src/plugins/pii-redact-plugin.ts
105
+ var DEFAULT_PII_PATTERNS = {
106
+ email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
107
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
108
+ creditCard: /\b(?:\d[ -]?){13,16}\b/g,
109
+ phone: /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g
110
+ };
111
+ var MAX_DEPTH = 50;
112
+ var PIIRedactPlugin = class {
113
+ patterns;
114
+ replacement;
115
+ constructor(options = {}) {
116
+ this.patterns = options.patterns ?? DEFAULT_PII_PATTERNS;
117
+ this.replacement = options.replacement ?? "***";
118
+ }
119
+ beforeLog(record) {
120
+ return { ...record, meta: this.redactValue(record.meta, /* @__PURE__ */ new Set(), 0) };
121
+ }
122
+ redactValue(value, seen, depth) {
123
+ if (depth > MAX_DEPTH) {
124
+ return value;
125
+ }
126
+ if (typeof value === "string") {
127
+ return this.redactText(value);
128
+ }
129
+ if (Array.isArray(value)) {
130
+ if (seen.has(value)) {
131
+ return value;
132
+ }
133
+ const nextSeen = new Set(seen).add(value);
134
+ return value.map((entry) => this.redactValue(entry, nextSeen, depth + 1));
135
+ }
136
+ if (value !== null && typeof value === "object") {
137
+ if (seen.has(value)) {
138
+ return value;
139
+ }
140
+ const nextSeen = new Set(seen).add(value);
141
+ const result = {};
142
+ for (const [key, entryValue] of Object.entries(value)) {
143
+ result[key] = this.redactValue(entryValue, nextSeen, depth + 1);
144
+ }
145
+ return result;
146
+ }
147
+ return value;
148
+ }
149
+ redactText(text) {
150
+ let redacted = text;
151
+ for (const pattern of Object.values(this.patterns)) {
152
+ const global = pattern.global ? pattern : new RegExp(pattern.source, `${pattern.flags}g`);
153
+ redacted = redacted.replace(global, this.replacement);
154
+ }
155
+ return redacted;
156
+ }
157
+ };
158
+
92
159
  // src/plugins/sampling-plugin.ts
93
160
  var SamplingPlugin = class {
94
161
  rate;
162
+ traceKey;
163
+ elevateAt;
164
+ transports;
165
+ maxBufferedRecords;
166
+ maxTraces;
95
167
  rng;
168
+ buffer = /* @__PURE__ */ new Map();
169
+ bufferedCount = 0;
170
+ elevated = /* @__PURE__ */ new Set();
96
171
  constructor(rate, options = {}) {
97
172
  if (rate < 0 || rate > 1) {
98
173
  throw new Error(`rate must be between 0 and 1, got ${String(rate)}`);
99
174
  }
100
175
  this.rate = rate;
101
176
  this.rng = options.rng ?? Math.random;
177
+ this.traceKey = options.traceKey ?? "traceId";
178
+ this.elevateAt = parseLevel(options.elevateAt ?? 40 /* ERROR */);
179
+ this.transports = options.transports;
180
+ this.maxBufferedRecords = options.maxBufferedRecords ?? 1e3;
181
+ this.maxTraces = options.maxTraces ?? 200;
102
182
  }
103
183
  beforeLog(record) {
104
- return this.rng() < this.rate ? record : null;
184
+ const transports = this.transports;
185
+ if (transports === void 0) {
186
+ return this.rng() < this.rate ? record : null;
187
+ }
188
+ const traceId = record.meta[this.traceKey];
189
+ if (traceId !== void 0 && this.elevated.has(traceId)) {
190
+ return record;
191
+ }
192
+ const keep = this.rng() < this.rate;
193
+ const reachedElevateLevel = parseLevel(record.level) >= this.elevateAt;
194
+ if (traceId !== void 0 && reachedElevateLevel) {
195
+ this.elevate(traceId, transports);
196
+ return record;
197
+ }
198
+ if (keep) {
199
+ return record;
200
+ }
201
+ if (traceId !== void 0) {
202
+ this.bufferRecord(traceId, record);
203
+ }
204
+ return null;
205
+ }
206
+ elevate(traceId, transports) {
207
+ this.elevated.add(traceId);
208
+ const buffered = this.buffer.get(traceId) ?? [];
209
+ this.buffer.delete(traceId);
210
+ this.bufferedCount -= buffered.length;
211
+ for (const bufferedRecord of buffered) {
212
+ for (const transport of transports) {
213
+ transport.write(transport.format(bufferedRecord), bufferedRecord);
214
+ }
215
+ }
216
+ }
217
+ bufferRecord(traceId, record) {
218
+ let records = this.buffer.get(traceId);
219
+ if (records) {
220
+ this.buffer.delete(traceId);
221
+ this.buffer.set(traceId, records);
222
+ } else {
223
+ if (this.buffer.size >= this.maxTraces) {
224
+ this.evictOldestTrace();
225
+ }
226
+ records = [];
227
+ this.buffer.set(traceId, records);
228
+ }
229
+ records.push(record);
230
+ this.bufferedCount += 1;
231
+ while (this.bufferedCount > this.maxBufferedRecords && this.buffer.size > 0) {
232
+ this.evictOldestTrace();
233
+ }
234
+ }
235
+ evictOldestTrace() {
236
+ const oldest = this.buffer.entries().next();
237
+ if (oldest.done) {
238
+ return;
239
+ }
240
+ const [oldestKey, oldestRecords] = oldest.value;
241
+ this.buffer.delete(oldestKey);
242
+ this.bufferedCount -= oldestRecords.length;
243
+ }
244
+ };
245
+ var GENESIS_HASH = "0".repeat(64);
246
+ function canonicalStringify(value) {
247
+ if (Array.isArray(value)) {
248
+ return `[${value.map((entry) => canonicalStringify(entry)).join(",")}]`;
249
+ }
250
+ if (value !== null && typeof value === "object") {
251
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
252
+ return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalStringify(entryValue)}`).join(",")}}`;
253
+ }
254
+ if (value === void 0) {
255
+ return "null";
256
+ }
257
+ return JSON.stringify(value);
258
+ }
259
+ function computeHash(record, prevHash) {
260
+ const restMeta = Object.fromEntries(Object.entries(record.meta).filter(([key]) => key !== "hash" && key !== "prevHash"));
261
+ const payload = canonicalStringify({
262
+ timestamp: record.timestamp,
263
+ level: record.level,
264
+ logger: record.logger,
265
+ message: record.message,
266
+ meta: restMeta
267
+ });
268
+ return crypto.createHash("sha256").update(`${prevHash}${payload}`).digest("hex");
269
+ }
270
+ var TamperEvidentPlugin = class {
271
+ genesisHash;
272
+ lastHash;
273
+ constructor(options = {}) {
274
+ this.genesisHash = options.genesisHash ?? GENESIS_HASH;
275
+ this.lastHash = this.genesisHash;
276
+ }
277
+ beforeLog(record) {
278
+ const prevHash = this.lastHash;
279
+ const digest = computeHash(record, prevHash);
280
+ const next = { ...record, meta: { ...record.meta, prevHash, hash: digest } };
281
+ this.lastHash = digest;
282
+ return next;
283
+ }
284
+ /**
285
+ * Returns `true` iff every record's hash matches its content plus the
286
+ * previous record's hash, in the given order. Returns `false` at the
287
+ * first break in the chain (an edited, removed, or reordered record).
288
+ */
289
+ static verifyChain(records, options = {}) {
290
+ let prevHash = options.genesisHash ?? GENESIS_HASH;
291
+ for (const record of records) {
292
+ const storedHash = record.meta.hash;
293
+ const storedPrevHash = record.meta.prevHash;
294
+ if (typeof storedHash !== "string" || storedPrevHash !== prevHash) {
295
+ return false;
296
+ }
297
+ if (computeHash(record, prevHash) !== storedHash) {
298
+ return false;
299
+ }
300
+ prevHash = storedHash;
301
+ }
302
+ return true;
303
+ }
304
+ };
305
+
306
+ // src/plugins/alerting-plugin.ts
307
+ function defaultDedupeKey(record) {
308
+ return `${record.level}:${record.logger}:${record.message}`;
309
+ }
310
+ var AlertingPlugin = class {
311
+ threshold;
312
+ dedupeWindowMs;
313
+ maxTrackedKeys;
314
+ dedupeKeyFn;
315
+ windows = /* @__PURE__ */ new Map();
316
+ constructor(options = {}) {
317
+ this.threshold = parseLevel(options.threshold ?? 40 /* ERROR */);
318
+ this.dedupeWindowMs = options.dedupeWindowMs ?? 3e5;
319
+ this.dedupeKeyFn = options.dedupeKey ?? defaultDedupeKey;
320
+ this.maxTrackedKeys = options.maxTrackedKeys ?? 500;
321
+ }
322
+ afterLog(record) {
323
+ if (parseLevel(record.level) < this.threshold) {
324
+ return;
325
+ }
326
+ const key = this.dedupeKeyFn(record);
327
+ const existing = this.windows.get(key);
328
+ if (existing) {
329
+ existing.count += 1;
330
+ return;
331
+ }
332
+ if (this.windows.size >= this.maxTrackedKeys) {
333
+ return;
334
+ }
335
+ const timer = setTimeout(() => {
336
+ this.flush(key);
337
+ }, this.dedupeWindowMs);
338
+ timer.unref();
339
+ this.windows.set(key, { record, count: 1, timer });
340
+ this.safeSend(record, 1);
341
+ }
342
+ flush(key) {
343
+ const window = this.windows.get(key);
344
+ this.windows.delete(key);
345
+ if (!window || window.count <= 1) {
346
+ return;
347
+ }
348
+ this.safeSend(window.record, window.count);
349
+ }
350
+ safeSend(record, occurrences) {
351
+ Promise.resolve().then(() => this.sendAlert(record, occurrences)).catch((error) => {
352
+ try {
353
+ this.onError?.(error, record);
354
+ } catch {
355
+ }
356
+ });
357
+ }
358
+ /** Cancel any pending dedupe-window timers. Call on logger shutdown. */
359
+ close() {
360
+ const windows = [...this.windows.values()];
361
+ this.windows.clear();
362
+ for (const window of windows) {
363
+ clearTimeout(window.timer);
364
+ }
365
+ }
366
+ };
367
+
368
+ // src/plugins/slack-alert-plugin.ts
369
+ async function fetchSlackSender(webhookUrl, body) {
370
+ const response = await fetch(webhookUrl, {
371
+ method: "POST",
372
+ headers: { "Content-Type": "application/json" },
373
+ body
374
+ });
375
+ if (!response.ok) {
376
+ throw new Error(
377
+ `SlackAlertPlugin: webhook returned HTTP ${String(response.status)} \u2014 check the webhook URL is still valid in Slack's app config`
378
+ );
379
+ }
380
+ }
381
+ function formatMessage(record, occurrences) {
382
+ const suffix = occurrences > 1 ? ` (x${String(occurrences)})` : "";
383
+ return `[${record.level}] ${record.logger}: ${record.message}${suffix}`;
384
+ }
385
+ var SlackAlertPlugin = class extends AlertingPlugin {
386
+ webhookUrl;
387
+ sender;
388
+ constructor(webhookUrl, options = {}) {
389
+ super(options);
390
+ this.webhookUrl = webhookUrl;
391
+ this.sender = options.sender ?? fetchSlackSender;
392
+ }
393
+ async sendAlert(record, occurrences) {
394
+ const body = JSON.stringify({ text: formatMessage(record, occurrences) });
395
+ await this.sender(this.webhookUrl, body);
396
+ }
397
+ };
398
+
399
+ // src/plugins/pagerduty-alert-plugin.ts
400
+ var ENDPOINT = "https://events.pagerduty.com/v2/enqueue";
401
+ var SEVERITY = { ERROR: "error", FATAL: "critical" };
402
+ async function fetchPagerDutySender(body) {
403
+ const response = await fetch(ENDPOINT, {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body
407
+ });
408
+ if (!response.ok) {
409
+ throw new Error(
410
+ `PagerDutyAlertPlugin: Events API returned HTTP ${String(response.status)} \u2014 check the routing key is a valid Events API v2 integration key`
411
+ );
412
+ }
413
+ }
414
+ var PagerDutyAlertPlugin = class extends AlertingPlugin {
415
+ routingKey;
416
+ sender;
417
+ constructor(routingKey, options = {}) {
418
+ super(options);
419
+ this.routingKey = routingKey;
420
+ this.sender = options.sender ?? fetchPagerDutySender;
421
+ }
422
+ async sendAlert(record, occurrences) {
423
+ let summary = `${record.logger}: ${record.message}`;
424
+ if (occurrences > 1) {
425
+ summary += ` (x${String(occurrences)})`;
426
+ }
427
+ const body = JSON.stringify({
428
+ routing_key: this.routingKey,
429
+ event_action: "trigger",
430
+ payload: {
431
+ summary,
432
+ severity: SEVERITY[record.level] ?? "error",
433
+ source: record.logger,
434
+ timestamp: record.timestamp,
435
+ custom_details: { occurrences, ...record.meta }
436
+ }
437
+ });
438
+ await this.sender(body);
439
+ }
440
+ };
441
+
442
+ // src/plugins/email-alert-plugin.ts
443
+ var EmailAlertPlugin = class extends AlertingPlugin {
444
+ smtpHost;
445
+ smtpPort;
446
+ fromAddr;
447
+ toAddrs;
448
+ username;
449
+ password;
450
+ useTls;
451
+ injectedSender;
452
+ transporter;
453
+ constructor(options) {
454
+ super(options);
455
+ this.smtpHost = options.smtpHost;
456
+ this.smtpPort = options.smtpPort;
457
+ this.fromAddr = options.fromAddr;
458
+ this.toAddrs = options.toAddrs;
459
+ this.username = options.username;
460
+ this.password = options.password;
461
+ this.useTls = options.useTls ?? true;
462
+ this.injectedSender = options.sender;
463
+ }
464
+ async sendAlert(record, occurrences) {
465
+ let subject = `[${record.level}] ${record.logger}`;
466
+ if (occurrences > 1) {
467
+ subject += ` (x${String(occurrences)})`;
468
+ }
469
+ const text = [
470
+ record.message,
471
+ "",
472
+ `occurrences: ${String(occurrences)}`,
473
+ `timestamp: ${record.timestamp}`,
474
+ `meta: ${JSON.stringify(record.meta)}`
475
+ ].join("\n");
476
+ const message = { from: this.fromAddr, to: this.toAddrs, subject, text };
477
+ if (this.injectedSender) {
478
+ await this.injectedSender(message);
479
+ return;
480
+ }
481
+ const transporter = this.transporter ?? await this.importTransporter();
482
+ await transporter.sendMail({ from: message.from, to: message.to.join(", "), subject: message.subject, text: message.text });
483
+ }
484
+ async importTransporter() {
485
+ let createTransport;
486
+ try {
487
+ const moduleName = "nodemailer";
488
+ const mod = await import(moduleName);
489
+ const resolved = mod.default?.createTransport ?? mod.createTransport;
490
+ if (!resolved) {
491
+ throw new Error("no createTransport export found");
492
+ }
493
+ createTransport = resolved;
494
+ } catch {
495
+ throw new Error(
496
+ "EmailAlertPlugin: install `nodemailer` to use this plugin without providing a `sender` \u2014 `npm install nodemailer`"
497
+ );
498
+ }
499
+ this.transporter = createTransport({
500
+ host: this.smtpHost,
501
+ port: this.smtpPort,
502
+ secure: false,
503
+ requireTLS: this.useTls,
504
+ auth: this.username && this.password ? { user: this.username, pass: this.password } : void 0
505
+ });
506
+ return this.transporter;
105
507
  }
106
508
  };
107
509
 
@@ -1320,7 +1722,10 @@ var Logger = class _Logger {
1320
1722
  this.name = name;
1321
1723
  this.currentLevel = parseLevel(options.level ?? 20 /* INFO */);
1322
1724
  this.transports = options.transports ? [...options.transports] : [];
1323
- this.plugins = options.plugins ? [...options.plugins] : [];
1725
+ this.plugins = [];
1726
+ for (const plugin of options.plugins ?? []) {
1727
+ this.use(plugin);
1728
+ }
1324
1729
  this.baseMeta = options.meta ? { ...options.meta } : {};
1325
1730
  }
1326
1731
  get level() {
@@ -1329,9 +1734,14 @@ var Logger = class _Logger {
1329
1734
  setLevel(level) {
1330
1735
  this.currentLevel = parseLevel(level);
1331
1736
  }
1332
- /** Register a plugin. Returns `this` so calls can be chained. */
1737
+ /**
1738
+ * Register a plugin, or a plain `beforeLog`-style function. A function is
1739
+ * wrapped internally as an anonymous `Plugin` (`FunctionPlugin`) — the
1740
+ * same middleware ergonomics as Express/Koa, without needing to read the
1741
+ * `Plugin` interface first. Returns `this` so calls can be chained.
1742
+ */
1333
1743
  use(plugin) {
1334
- this.plugins.push(plugin);
1744
+ this.plugins.push(typeof plugin === "function" ? new FunctionPlugin(plugin) : plugin);
1335
1745
  return this;
1336
1746
  }
1337
1747
  /** Close every attached transport. Call on shutdown to flush buffered writes. */
@@ -1411,8 +1821,9 @@ var Logger = class _Logger {
1411
1821
  };
1412
1822
 
1413
1823
  // src/index.ts
1414
- var VERSION = "0.2.0";
1824
+ var VERSION = "0.3.0";
1415
1825
 
1826
+ exports.AlertingPlugin = AlertingPlugin;
1416
1827
  exports.AppInsightsTransport = AppInsightsTransport;
1417
1828
  exports.BaseQueueTransport = BaseQueueTransport;
1418
1829
  exports.BaseSQLTransport = BaseSQLTransport;
@@ -1422,11 +1833,15 @@ exports.CloudWatchTransport = CloudWatchTransport;
1422
1833
  exports.CollectingTransport = CollectingTransport;
1423
1834
  exports.ConsoleTransport = ConsoleTransport;
1424
1835
  exports.ContextPlugin = ContextPlugin;
1836
+ exports.DEFAULT_PII_PATTERNS = DEFAULT_PII_PATTERNS;
1425
1837
  exports.DEFAULT_REDACTED_KEYS = DEFAULT_REDACTED_KEYS;
1426
1838
  exports.DatadogTransport = DatadogTransport;
1427
1839
  exports.DynamoDBTransport = DynamoDBTransport;
1428
1840
  exports.ElasticsearchTransport = ElasticsearchTransport;
1841
+ exports.EmailAlertPlugin = EmailAlertPlugin;
1429
1842
  exports.FileTransport = FileTransport;
1843
+ exports.FunctionPlugin = FunctionPlugin;
1844
+ exports.GENESIS_HASH = GENESIS_HASH;
1430
1845
  exports.HTTPTransport = HTTPTransport;
1431
1846
  exports.JSONFormatter = JSONFormatter;
1432
1847
  exports.KafkaTransport = KafkaTransport;
@@ -1435,6 +1850,8 @@ exports.Logger = Logger;
1435
1850
  exports.MongoDBTransport = MongoDBTransport;
1436
1851
  exports.MySQLTransport = MySQLTransport;
1437
1852
  exports.NewRelicTransport = NewRelicTransport;
1853
+ exports.PIIRedactPlugin = PIIRedactPlugin;
1854
+ exports.PagerDutyAlertPlugin = PagerDutyAlertPlugin;
1438
1855
  exports.PostgresTransport = PostgresTransport;
1439
1856
  exports.PubSubTransport = PubSubTransport;
1440
1857
  exports.RabbitMQTransport = RabbitMQTransport;
@@ -1443,6 +1860,8 @@ exports.RedisTransport = RedisTransport;
1443
1860
  exports.SQLiteTransport = SQLiteTransport;
1444
1861
  exports.SQSTransport = SQSTransport;
1445
1862
  exports.SamplingPlugin = SamplingPlugin;
1863
+ exports.SlackAlertPlugin = SlackAlertPlugin;
1864
+ exports.TamperEvidentPlugin = TamperEvidentPlugin;
1446
1865
  exports.Transport = Transport;
1447
1866
  exports.VERSION = VERSION;
1448
1867
  exports.createRecord = createRecord;