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 +142 -8
- package/dist/index.cjs +424 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +308 -13
- package/dist/index.d.ts +308 -13
- package/dist/index.mjs +416 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
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,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
- **
|
|
24
|
-
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; `beforeLog`/`afterLog`/`onError` hooks
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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.
|
|
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;
|