@spinajs/log 2.0.491 → 2.0.494

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
@@ -1,744 +1,744 @@
1
- # `@spinajs/log`
2
-
3
- The logging system for SpinaJS. You resolve a `Log` service, call level methods
4
- (`info`, `error`, …), and a **rules + targets** configuration decides which
5
- logger writes **where** and in **what format**. It supports plain-text layouts
6
- and structured JSON, buffered file logging with rotation/retention, network
7
- sinks (Grafana Loki, OTLP), composable wrapper targets, a filter pipeline,
8
- ambient request-scoped context with trace correlation, and runtime level
9
- control.
10
-
11
- ## Table of contents
12
-
13
- - [Which package do I import?](#which-package-do-i-import)
14
- - [Quick start](#quick-start)
15
- - [Log levels](#log-levels)
16
- - [Loggers](#loggers)
17
- - [Logging API](#logging-api)
18
- - [Layouts / templates](#layouts--templates)
19
- - [Targets (sinks)](#targets-sinks)
20
- - [Rules](#rules)
21
- - [Filters](#filters)
22
- - [Structured logging](#structured-logging)
23
- - [Async context & correlation](#async-context--correlation)
24
- - [File archiving](#file-archiving)
25
- - [Runtime level control](#runtime-level-control)
26
- - [Configuration reference](#configuration-reference)
27
- - [Extending](#extending)
28
- - [Package map](#package-map)
29
-
30
- ## Which package do I import?
31
-
32
- | Package | Use it for |
33
- | --- | --- |
34
- | `@spinajs/log` | **Application code.** Resolve `Log` and log. Ships the console/file/JSON/memory targets, the filter pipeline, `LogContext`, and runtime level control. |
35
- | `@spinajs/log-common` | Only when writing a **custom target or filter** (abstract `LogTarget` / `LogFilter`, `ILogEntry`, `LogLevel`, `createLogMessageObject`, `serializeError`/`safeStringify`, `BatchQueue`). No implementation, so it avoids a circular dependency. |
36
- | `@spinajs/internal-logger` | Only inside low-level packages (DI, configuration) that must log **before** the logger/config exists. Messages buffer and replay into the real logger once configuration resolves. |
37
- | `@spinajs/log-source-graphana-loki` | The Grafana **Loki** target (`GraphanaLogTarget`). |
38
- | `@spinajs/log-otlp` | The **OTLP/HTTP** export target (`OtlpLogTarget`) for any OpenTelemetry backend. |
39
- | `@spinajs/telemetry` | Prometheus metrics + Apdex + an HTTP request-timing middleware. Metrics, not a log sink — see its own README. |
40
-
41
- ## Quick start
42
-
43
- ```ts
44
- import { DI } from "@spinajs/di";
45
- import { Log } from "@spinajs/log";
46
-
47
- const log = await DI.resolve(Log, ["my-module"]); // logger named "my-module"
48
-
49
- log.info("started");
50
- log.info("user %s logged in", userId); // printf-style args
51
- log.error(err, "could not connect to %s", host); // Error first, then message
52
- log.info({ reqId, sku }, "checkout started"); // structured fields (merging object)
53
- ```
54
-
55
- With no configuration the logger writes every level to a colored console.
56
-
57
- ## Log levels
58
-
59
- Eight levels, lowest to highest severity:
60
-
61
- ```
62
- trace < debug < info < success < warn < error < fatal < security
63
- ```
64
-
65
- `success` and `security` are SpinaJS extras (a positive/notice level and a
66
- top-of-scale audit level). A rule's `level` is the **minimum**; lower-severity
67
- messages for that logger are dropped.
68
-
69
- Each level also maps to the OpenTelemetry **SeverityNumber** (1–24), emitted by
70
- the JSON/OTLP targets so any observability backend can rank/filter by severity:
71
-
72
- | Level | SeverityNumber |
73
- | --- | --- |
74
- | trace | 1 |
75
- | debug | 5 |
76
- | info | 9 |
77
- | success | 11 |
78
- | warn | 13 |
79
- | error | 17 |
80
- | fatal | 21 |
81
- | security | 23 |
82
-
83
- ## Loggers
84
-
85
- - **Named** — `DI.resolve(Log, ["name"])`. The same name returns the same logger.
86
- - **`@Logger` decorator** — inject a logger into a class property:
87
- ```ts
88
- import { Logger, Log } from "@spinajs/log";
89
- class UserService {
90
- @Logger("UserService") protected Log: Log;
91
- save() { this.Log.info("saving"); }
92
- }
93
- ```
94
- - **Child loggers** — `const child = log.child("db", { pool: 1 });` creates a
95
- logger named `parent.db` that inherits the parent's variables plus any extra.
96
- - **Per-logger variables** — `log.addVariable("region", "eu");` makes `${region}`
97
- available in that logger's layouts and structured records.
98
- - **Timers** — `log.timeStart("q"); …; const ms = log.timeEnd("q");` returns the
99
- elapsed milliseconds.
100
-
101
- ## Logging API
102
-
103
- Every level method (`trace`/`debug`/`info`/`warn`/`error`/`fatal`/`security`/`success`)
104
- accepts three call shapes, dispatched by the **first argument**:
105
-
106
- ```ts
107
- log.info("plain message");
108
- log.info("formatted %s = %d", name, count); // printf args
109
- log.error(new Error("boom"), "while saving %s", id); // Error first -> structured `error`
110
- log.info({ reqId: "abc", userId: 42 }, "handled"); // plain object first -> merged fields
111
- ```
112
-
113
- - **Error first** → the error is serialized into a structured `error` field and
114
- the message/args follow.
115
- - **Plain object first** (not an `Error`/array) → its keys are merged into the
116
- entry's variables (available as `${key}` in layouts and as fields in JSON),
117
- the second argument is the format string.
118
- - **String first** → it is the message / printf format string.
119
-
120
- **printf specifiers** (in the message string):
121
-
122
- | Spec | Meaning |
123
- | --- | --- |
124
- | `%s` | string |
125
- | `%d` | number |
126
- | `%i` | `parseInt` |
127
- | `%f` | `parseFloat` |
128
- | `%j` / `%o` / `%O` | JSON / object |
129
- | `%%` | literal `%` (consumes no argument) |
130
-
131
- Leftover arguments are appended space-separated.
132
-
133
- ## Layouts / templates
134
-
135
- Text targets render each entry through a **layout** string using `${…}`
136
- placeholders. The default layout is:
137
-
138
- ```
139
- ${datetime} ${level} ${message}${?error} Exception: ${error:message}${/error} (${logger})
140
- ```
141
-
142
- Available constructs:
143
-
144
- | Construct | Renders |
145
- | --- | --- |
146
- | `${datetime}` | current date-time |
147
- | `${date:dd_MM_yyyy}` | formatted date (Luxon-style format) |
148
- | `${level}` | upper-case level, e.g. `ERROR` |
149
- | `${message}` | the formatted message |
150
- | `${logger}` | the logger name |
151
- | `${myVar}` | any per-logger / merged / ambient variable |
152
- | `${error:message}` | sub-property access on a variable (here the structured error's `message`) |
153
- | `${?error} … ${/error}` | conditional block — rendered only when `error` is truthy |
154
- | `${callsite}` | the caller's `file:line` (opt-in — see below) |
155
-
156
- Set a target's `layout` to override the default, e.g.
157
- `"${datetime} ${level} ${message} @ ${callsite} (${logger})"`.
158
-
159
- **`${callsite}`** is captured only when *some* target's layout references it, so
160
- logging stays zero-cost otherwise (no stack is walked). It resolves to
161
- `basename:line` and is best-effort (empty string if the runtime stack can't be
162
- parsed).
163
-
164
- ## Targets (sinks)
165
-
166
- A **target** is where messages go. Each configured target has a `name`
167
- (referenced by rules) and a `type` (the DI key). Common options
168
- (`ICommonTargetOptions`): `name`, `type`, `enabled` (default `true`), `layout`.
169
-
170
- > **Per-target filters.** A target definition may carry its own `filters` list
171
- > (same shape as `logger.filters`). These run **only** when writing to that
172
- > target, **after** the logger-level pipeline, and drop/mutate the entry for
173
- > **that target only** — the entry is cloned per target first, so a mutating
174
- > filter (e.g. `WhenRepeatedFilter`'s `(xN)`) never bleeds into other targets:
175
- >
176
- > ```js
177
- > targets: [
178
- > { name: "Audit", type: "FileTarget",
179
- > filters: [{ type: "MatchFilter", pattern: "secret", mode: "drop" }] },
180
- > { name: "Console", type: "ConsoleTarget" }, // still sees everything
181
- > ]
182
- > ```
183
-
184
- > **`enabled: false` targets are never instantiated.** A target definition
185
- > marked `enabled: false` is skipped at resolve time — its class is never
186
- > constructed, so a disabled `FileTarget` never opens its file, starts its flush
187
- > timer, or spins up its archive service. A rule that references only disabled
188
- > targets is silently skipped (treated as intentionally not routed); a rule that
189
- > references a target **name that does not exist at all** still throws
190
- > `InvalidOption`.
191
-
192
- > **Config shape note:** some targets read their settings **flat** on the target
193
- > definition (Console `theme`/`layout`, `MemoryTarget.limit`, `JsonTarget.stream`),
194
- > while File/JSON-file and the wrapper targets read them **nested under an
195
- > `options` object**. Loki and OTLP accept either. Each example below uses the
196
- > form that target expects.
197
-
198
- > **Runtime targets (attach/detach a sink).** Besides config, you can attach or
199
- > detach a target on a specific logger **at runtime** (bunyan `addStream` style):
200
- >
201
- > ```js
202
- > const log = DI.resolve(Log, ["my-logger"]);
203
- >
204
- > // attach – resolves the target ( honoring enabled:false ) and returns the instance
205
- > const mem = log.addTarget(
206
- > { name: "Live", type: "MemoryTarget" },
207
- > { level: "warn" } // optional level window [level, maxLevel ?? security] + filters
208
- > );
209
- >
210
- > // detach – flushes buffered entries, then removes it ( does NOT dispose it )
211
- > await log.removeTarget("Live");
212
- > ```
213
- >
214
- > `addTarget(def, opts?)` returns the resolved target instance (or `undefined`
215
- > when `def.enabled === false`). `opts` accepts `level` / `maxLevel` (the level
216
- > window) and `filters` (appended after `def.filters`). Adding a target whose
217
- > `name` already exists **replaces** the previous one (flushed first), so a name
218
- > is never duplicated, and the `MinLevel` / `${callsite}` gates are recomputed.
219
- > `removeTarget(name)` force-flushes each matching target **before** detaching so
220
- > no buffered entry is lost; it is a no-op when no target has that name.
221
-
222
- | `type` | Class | Package | Writes to |
223
- | --- | --- | --- | --- |
224
- | `ConsoleTarget` | `ColoredConsoleTarget` / `BrowserConsoleTarget` | `@spinajs/log` | stdout/stderr (ANSI colors on Node; devtools styling in the browser) |
225
- | `FileTarget` | `FileTarget` | `@spinajs/log` | a file via `@spinajs/fs`, buffered, with rotation/retention/zip |
226
- | `JsonTarget` | `JsonTarget` | `@spinajs/log` | **stdout** as newline-delimited JSON (NDJSON) |
227
- | `JsonFileTarget` | `JsonFileTarget` | `@spinajs/log` | a **file** as NDJSON (inherits FileTarget rotation) |
228
- | `MemoryTarget` | `MemoryTarget` | `@spinajs/log` | an in-memory ring buffer (readable in-process) |
229
- | `BlackHoleTarget` | `BlackHoleTarget` | `@spinajs/log` | nowhere (discards; useful in tests) |
230
- | `SplitGroupTarget` | `SplitGroupTarget` | `@spinajs/log` | fans one target out to many |
231
- | `AutoFlushTarget` | `AutoFlushTarget` | `@spinajs/log` | wraps a target; force-flushes it on high-severity entries |
232
- | `RetryingTarget` | `RetryingTarget` | `@spinajs/log` | wraps a target; retries its `write` on rejection |
233
- | `FallbackGroupTarget` | `FallbackGroupTarget` | `@spinajs/log` | ordered fallback across targets (+ drop-hook) |
234
- | `GraphanaLogTarget` | `GraphanaLokiLogTarget` | `@spinajs/log-source-graphana-loki` | Grafana Loki over HTTP, batched |
235
- | `OtlpLogTarget` | `OtlpLogTarget` | `@spinajs/log-otlp` | any OTLP/HTTP backend at `/v1/logs` |
236
-
237
- ### Console
238
-
239
- ```js
240
- { name: "Console", type: "ConsoleTarget" }
241
- ```
242
-
243
- Node uses ANSI colors per level (override the palette with a `theme` map);
244
- the browser build maps levels to `console.debug/log/warn/error`.
245
-
246
- > **Browser caveat:** because output is formatted and dispatched through the
247
- > logger, browser devtools attribute log lines to the console target, not your
248
- > call site. Use `${callsite}` in the layout if you need the origin.
249
-
250
- ### File
251
-
252
- Writes through the [`@spinajs/fs`](../fs) abstraction, so the active log and its
253
- archives can live on any registered provider (local disk, S3, FTP, …). Options
254
- live under `options`:
255
-
256
- ```js
257
- {
258
- name: "File",
259
- type: "FileTarget",
260
- options: {
261
- path: "logs/log_${logger}_${date:dd_MM_yyyy}.txt", // required; variables allowed
262
- archivePath: "logs/archive",
263
- maxSize: 1024 * 1024, // rotate past this many bytes
264
- compress: true, // zip archived files
265
- maxBufferSize: 100, // buffered messages before a flush
266
- maxQueueSize: 100000, // hard in-memory cap; drops oldest if a sink is stuck
267
- flushInterval: 1000, // ms; flush a partial buffer at least this often
268
- archiveStrategy: "SizeLogArchiveStrategy",
269
- retentionStrategies: ["CountLogRetentionStrategy"],
270
- maxArchiveFiles: 5,
271
- maxAge: 7 * 24 * 60 * 60, // seconds
272
- archiveInterval: 60, // seconds between size checks
273
- },
274
- }
275
- ```
276
-
277
- | option | default | meaning |
278
- | --- | --- | --- |
279
- | `path` | *required* | active log path, relative to the `fs` provider base path (variables allowed) |
280
- | `archivePath` | log dir | archive directory, relative to the `archiveFs` provider |
281
- | `fs` | `fs-log-default` | provider for the active log (`fs-log-default` is registered automatically at `process.cwd()`) |
282
- | `archiveFs` | = `fs` | provider archives are moved to |
283
- | `archiveStrategy` | `SizeLogArchiveStrategy` | rotation strategy class name |
284
- | `retentionStrategies` | `["CountLogRetentionStrategy"]` | ordered retention strategy class names |
285
- | `maxSize` | `1048576` | rotate when the active log exceeds this many bytes |
286
- | `archiveInterval` | `60` | seconds between size checks |
287
- | `rotate` | — | cron expression for `CronLogArchiveStrategy` (6-field, seconds supported) |
288
- | `compress` | `false` | zip the archived file, then delete the raw copy |
289
- | `maxBufferSize` | `100` | buffered messages before a flush |
290
- | `maxQueueSize` | `100000` | hard cap; oldest buffered messages are dropped past it |
291
- | `flushInterval` | `1000` | periodic flush tick in ms |
292
- | `maxArchiveFiles` | `5` | archives to keep (`CountLogRetentionStrategy`) |
293
- | `maxAge` | `604800` | max archive age in seconds (`AgeLogRetentionStrategy`) |
294
-
295
- Writes are buffered and flushed as one batched `fs.append`, guarded by a
296
- write-lock so a rotation never races an append; a failed append is retried
297
- (never silently dropped, up to the `maxQueueSize` cap).
298
-
299
- ### JSON (stdout) and JSON file
300
-
301
- `JsonTarget` emits one JSON object per line to stdout — ideal for container log
302
- collectors (promtail/Loki, Filebeat/Elastic, Datadog, CloudWatch) that index
303
- fields instead of parsing text:
304
-
305
- ```js
306
- { name: "Json", type: "JsonTarget", stream: "stdout" } // or "stderr"
307
- ```
308
-
309
- A record looks like:
310
-
311
- ```json
312
- {"time":"2026-07-15T…","severityNumber":17,"level":"ERROR","logger":"checkout","message":"save failed","reqId":"abc","error":{"name":"Error","message":"save failed","stack":"…","code":"ECONNREFUSED"}}
313
- ```
314
-
315
- `JsonFileTarget` writes the same NDJSON to a **file**, reusing all of
316
- FileTarget's rotation/retention/zip (configure it exactly like `FileTarget`
317
- under `options`, with `type: "JsonFileTarget"`). Both stamp `time` at log time
318
- and serialize with a never-throwing, circular-safe stringifier.
319
-
320
- ### Memory (ring buffer)
321
-
322
- Keeps the last `limit` entries in memory so a debug endpoint or a crash handler
323
- can read recent context back in-process:
324
-
325
- ```js
326
- { name: "Memory", type: "MemoryTarget", limit: 200 } // default 100
327
- ```
328
-
329
- ```ts
330
- const ring = DI.resolve<MemoryTarget>("MemoryTarget");
331
- ring.getRecords(); // ILogEntry[] (newest last); ring.clear() to empty
332
- ```
333
-
334
- ### BlackHole
335
-
336
- ```js
337
- { name: "Null", type: "BlackHoleTarget" } // discards everything
338
- ```
339
-
340
- ### Wrapper targets
341
-
342
- Wrappers decorate inner target definitions (given under `options`).
343
-
344
- **SplitGroup** — fan one logical target out to many sinks:
345
-
346
- ```js
347
- { name: "Multi", type: "SplitGroupTarget", options: { targets: [
348
- { name: "Console", type: "ConsoleTarget" },
349
- { name: "File", type: "FileTarget", options: { path: "logs/app.log" } },
350
- ]}}
351
- ```
352
-
353
- **AutoFlush** — force-flush an inner (buffered) target when a high-severity entry
354
- arrives, so a crash-level event is never left buffered:
355
-
356
- ```js
357
- { name: "SafeFile", type: "AutoFlushTarget", options: {
358
- target: { name: "File", type: "FileTarget", options: { path: "logs/app.log" } },
359
- flushLevel: "error", // default "error"
360
- }}
361
- ```
362
-
363
- **Retrying** — retry an inner target's `write` on rejection with exponential
364
- backoff + jitter:
365
-
366
- ```js
367
- { name: "RetryOut", type: "RetryingTarget", options: {
368
- target: { name: "Custom", type: "MyTarget" },
369
- maxAttempts: 3, // default 3
370
- delayMs: 100, // default 100
371
- }}
372
- ```
373
-
374
- **FallbackGroup** — an ordered list; write advances to the next target when the
375
- primary **rejects** (write-rejection contract), *and* a drop-hook chains entries
376
- a self-healing network target **gives up on** (buffer overflow or a non-retryable
377
- delivery failure) to the next target — a durable fallback for a down sink, with
378
- no duplicates:
379
-
380
- ```js
381
- { name: "Durable", type: "FallbackGroupTarget", options: { targets: [
382
- { name: "Otlp", type: "OtlpLogTarget", options: { endpoint: "http://collector:4318" } },
383
- { name: "Spill", type: "JsonFileTarget", options: { path: "logs/undelivered.ndjson" } },
384
- ]}}
385
- ```
386
-
387
- ### Grafana Loki (`@spinajs/log-source-graphana-loki`)
388
-
389
- ```js
390
- { name: "Loki", type: "GraphanaLogTarget", options: {
391
- host: "http://localhost:3100",
392
- auth: { username: "admin", password: "admin" }, // optional (unauthenticated Loki allowed)
393
- labels: { app: "my-app" },
394
- interval: 3000, bufferSize: 10, maxBufferSize: 1000, timeout: 1000,
395
- }}
396
- ```
397
-
398
- Batched HTTP push with exponential-backoff + jitter retry (honoring
399
- `Retry-After`, retrying only network errors and 429/502/503/504); non-retryable
400
- errors surface instead of looping. The primary buffer is bounded.
401
-
402
- ### OTLP (`@spinajs/log-otlp`)
403
-
404
- Export to any OpenTelemetry backend (OTel Collector, Grafana/Tempo, Datadog, …):
405
-
406
- ```js
407
- { name: "Otlp", type: "OtlpLogTarget", options: {
408
- endpoint: "http://localhost:4318", // POSTs to /v1/logs
409
- headers: { Authorization: "Bearer …" }, // optional
410
- resource: { "service.name": "my-app" }, // resource attributes
411
- scopeName: "@spinajs/log",
412
- interval: 3000, bufferSize: 10, maxBufferSize: 1000, timeout: 5000,
413
- }}
414
- ```
415
-
416
- Maps each entry to the OTLP Logs model — `severityNumber`, `body`, resource +
417
- record attributes, `traceId`/`spanId` from the request trace context, and a
418
- structured `error` to `exception.type`/`exception.message`/`exception.stacktrace`
419
- semantic attributes. Batched with the same resilience retry as Loki.
420
-
421
- ## Rules
422
-
423
- A **rule** binds a logger-name pattern to a minimum `level` and one or more
424
- `target` names:
425
-
426
- ```js
427
- { name: "http/*/controller", level: "info", target: ["Console", "File"] }
428
- ```
429
-
430
- ### Level windows (`maxLevel`)
431
-
432
- `level` is the **lower** bound. Add an optional `maxLevel` to route only a level
433
- **window** `[level, maxLevel]` (inclusive) — e.g. warn/error but **not**
434
- fatal/security:
435
-
436
- ```js
437
- { name: "*", level: "warn", maxLevel: "error", target: "Ops" }
438
- ```
439
-
440
- Without `maxLevel` the upper bound defaults to the highest level (`security`), so
441
- a plain min-only rule is unchanged.
442
-
443
- Several rules may route to the **same** target with **different** windows; the
444
- target then accepts the **union** of those windows. So two rules `info..info` and
445
- `error..error` to one target deliver `info` and `error` but **not** a `warn`
446
- between them.
447
-
448
- > `maxLevel` does **not** lower the per-logger `MinLevel` fast-gate: a call above
449
- > every window still builds the entry and is then filtered out per target — the
450
- > gate only tracks the lowest `level` across rules.
451
-
452
- Name matching uses glob semantics:
453
-
454
- - `*` — any logger name.
455
- - `prefix*` — names starting with `prefix`.
456
- - `a.b.*` — dotted namespaces.
457
- - an exact name matches only itself.
458
-
459
- ### Ordered, additive matching (`final`)
460
-
461
- Rules are evaluated **in config order**, and matching is **additive** (NLog-style):
462
- **every** rule whose pattern matches a logger applies, so a logger matched by both
463
- `*` and a specific rule routes to **both** (targets are de-duped downstream, so a
464
- target hit by two matching rules still receives each entry once).
465
-
466
- A matched rule marked `final: true` **stops** evaluation of any *later* rules; that
467
- final rule and all earlier matched rules still apply.
468
-
469
- ```js
470
- rules: [
471
- { name: "db.pool", level: "trace", target: "PoolDebug", final: true }, // stops here
472
- { name: "*", level: "info", target: "Console" }, // skipped for db.pool
473
- ]
474
- ```
475
-
476
- - `db.pool` matches the first rule, applies it, and stops — the later `*` is **not**
477
- applied, so `db.pool` routes **only** to `PoolDebug`.
478
- - any other logger doesn't match `db.pool`, falls through, and routes to `Console`.
479
-
480
- > **Migration from the old behavior.** Previously a specific rule *dropped* the `*`
481
- > catch-all, so adding a rule for one logger silently **excluded** it from the
482
- > global console/file. Now the specific rule is **additive** — that logger reaches
483
- > both its own target **and** the catch-all. To restore the old "this logger goes
484
- > **only** here" behavior, mark its rule `final: true` and place it **before** the
485
- > `*` catch-all (as above).
486
-
487
- ## Filters
488
-
489
- Filters run in order per logger and can drop or modify entries. Configure a list
490
- under `logger.filters`; each item's `type` is a DI-registered filter. A filter
491
- returns the (possibly modified) entry to keep, or drops it.
492
-
493
- ```js
494
- logger: {
495
- filters: [
496
- { type: "LevelFilter", min: "warn" },
497
- { type: "MatchFilter", pattern: "healthcheck", mode: "drop" },
498
- { type: "RateLimitFilter", limit: 100, intervalSeconds: 10 },
499
- { type: "WhenRepeatedFilter", timeout: 10 },
500
- ],
501
- // …targets, rules
502
- }
503
- ```
504
-
505
- | Filter | Options | Effect |
506
- | --- | --- | --- |
507
- | `WhenRepeatedFilter` | `timeout` (s, default 10), `maxKeys` (default 1024) | collapses identical repeated entries within the window into one, appending `(xN)` when logging resumes |
508
- | `LevelFilter` | `min`, `max` (level names) | keeps only entries whose level is within `[min, max]` |
509
- | `MatchFilter` | `pattern`, `field` (default `message`), `mode` (`keep`/`drop`, default `keep`), `flags` | regex-match a variable; keep on match (or drop, in `drop` mode); an invalid pattern is a no-op |
510
- | `RateLimitFilter` | `limit`, `intervalSeconds`, `key` (optional variable) | fixed-window rate limit; drops overflow, per-key or global |
511
-
512
- Filters run **after** the near-zero-cost level gate, so disabled levels never
513
- reach them. The legacy `logger.whenRepeated` option still works (mapped to a
514
- prepended `WhenRepeatedFilter`).
515
-
516
- The same filter list can also be attached **per target** (`targets[].filters`) to
517
- filter for one sink only — see [Targets](#targets-sinks). Per-target filters run
518
- **after** the logger-level pipeline on a per-target clone, so a filter that mutates
519
- the entry there never affects other targets.
520
-
521
- ## Structured logging
522
-
523
- Use `JsonTarget`/`JsonFileTarget` (or Loki/OTLP) to emit machine-readable
524
- records. The pieces:
525
-
526
- - **Serializer registry** — registered field serializers run when an entry is
527
- built. The default `error` serializer turns an `Error` into
528
- `{ name, message, stack, code, signal }`, walking the `.cause` /
529
- `AggregateError` chain into `stack`. Register your own:
530
- ```ts
531
- import { registerSerializer } from "@spinajs/log-common";
532
- registerSerializer("req", (r: any) => ({ method: r.method, url: r.url }));
533
- // then: log.info({ req }, "handled")
534
- ```
535
- A serializer that throws degrades to `{ serializerError }` — logging never
536
- crashes the caller.
537
- - **Merging-object fields** — `log.info({ reqId, sku }, "…")` adds `reqId`/`sku`
538
- as first-class fields.
539
- - **`safeStringify`** — the JSON targets serialize with a never-throwing,
540
- `[Circular]`-safe stringifier, so a circular field can't break logging.
541
- - **`severityNumber`** — the OTel severity number is included on JSON/OTLP
542
- records for backend severity ranking.
543
-
544
- ## Async context & correlation
545
-
546
- `LogContext` provides ambient, per-operation variables over an
547
- `AsyncLocalStorage` shared with `@spinajs/http` — so anything logged inside a
548
- request automatically carries its context with zero threading.
549
-
550
- ```ts
551
- import { LogContext } from "@spinajs/log";
552
-
553
- LogContext.with({ requestId: "abc", tenant: "acme" }, async () => {
554
- // any logger, any depth, across awaits:
555
- log.info("deep inside"); // entry carries requestId + tenant
556
- });
557
- ```
558
-
559
- - `LogContext.with(vars, fn)` — run `fn` with `vars` merged onto the current
560
- context (copy-on-write; nesting accumulates).
561
- - `LogContext.active()` — the current context (or `{}`).
562
- - `LogContext.set(key, value)` — late-bind a value onto the active context.
563
- - `LogContext.bind(fn)` — capture the context and re-attach it to a detached
564
- callback / event handler.
565
-
566
- Only **scalar** values (string/number/boolean/bigint) from the ambient context
567
- are projected into log lines — objects/arrays/Dates are skipped as noise (pass
568
- structured payloads explicitly per call). Inside an HTTP request the context is
569
- `req.storage`, so logs automatically carry `requestId` and `realIp`.
570
-
571
- **Trace correlation** — the http `RequestId` middleware continues an inbound W3C
572
- `traceparent` (or starts a new trace) and seeds `traceId`/`spanId` into the
573
- context, so every log line across services shares a trace id (and they surface as
574
- top-level fields on OTLP records). Helpers `parseTraceparent`,
575
- `formatTraceparent`, and `newTraceContext` are exported for custom propagation.
576
-
577
- ## File archiving
578
-
579
- `FileTarget`/`JsonFileTarget` rotate and prune via strategies selected by class
580
- name:
581
-
582
- **Rotation** (when to archive) — one strategy:
583
-
584
- - `SizeLogArchiveStrategy` — interval timer; rotates when the active log passes `maxSize`.
585
- - `CronLogArchiveStrategy` — rotates on the `rotate` cron expression (6-field, seconds supported).
586
-
587
- **Retention** (which archives to delete) — an ordered list, so policies compose:
588
-
589
- - `CountLogRetentionStrategy` — keep the newest `maxArchiveFiles`.
590
- - `AgeLogRetentionStrategy` — delete archives older than `maxAge` seconds.
591
-
592
- ```js
593
- { name: "File", type: "FileTarget", options: {
594
- path: "logs/app.log",
595
- rotate: "0 0 1 * * *", // 1am daily
596
- archiveStrategy: "CronLogArchiveStrategy",
597
- retentionStrategies: ["CountLogRetentionStrategy", "AgeLogRetentionStrategy"],
598
- maxArchiveFiles: 5,
599
- maxAge: 7 * 24 * 60 * 60,
600
- compress: true,
601
- }}
602
- ```
603
-
604
- Custom strategies extend `LogArchiveStrategy` / `LogRetentionStrategy`, register
605
- in DI, and are named in the config. The browser build omits `FileTarget` and the
606
- archive module (and never pulls in `@spinajs/fs`).
607
-
608
- ## Runtime level control
609
-
610
- Every logger supports a runtime override on top of its rule-derived minimum
611
- level, with a near-zero-cost disabled path (a disabled call returns before
612
- building an entry):
613
-
614
- ```ts
615
- log.getLevel(); // current effective LogLevel
616
- log.setLevel("error"); // gate everything below error (persists in the browser)
617
- log.setDefaultLevel("info"); // set only if nothing is already overridden/persisted
618
- log.enableAll(); // = setLevel("trace")
619
- log.disableAll(); // silence everything
620
- log.resetLevel(); // back to the rule-derived level
621
- ```
622
-
623
- In the browser the chosen level persists to `localStorage` (cookie fallback), so
624
- it survives reloads; on Node persistence is a no-op. `setLevel` accepts a level
625
- name or a `LogLevel` value (validated via `normalizeLevel`).
626
-
627
- ## Configuration reference
628
-
629
- A complete `logger` configuration, validated against the schema in
630
- `src/schemas/log.configuration.ts` (`targets` and `rules` are required non-empty
631
- arrays; a target needs `name` + `type`; a rule needs `name` + `level` + `target`):
632
-
633
- ```js
634
- module.exports = {
635
- logger: {
636
- variables: {},
637
- targets: [
638
- { name: "Console", type: "ConsoleTarget" },
639
- { name: "Json", type: "JsonTarget", stream: "stdout" },
640
- { name: "File", type: "FileTarget", options: {
641
- path: "logs/log_${logger}_${date:dd_MM_yyyy}.txt",
642
- archivePath: "logs/archive",
643
- maxSize: 1024 * 1024,
644
- compress: true,
645
- maxBufferSize: 8 * 1024,
646
- retentionStrategies: ["CountLogRetentionStrategy", "AgeLogRetentionStrategy"],
647
- maxArchiveFiles: 5,
648
- maxAge: 7 * 24 * 60 * 60,
649
- }},
650
- ],
651
- filters: [
652
- { type: "WhenRepeatedFilter", timeout: 10 },
653
- ],
654
- rules: [
655
- { name: "*", level: "info", target: "Console" }, // everything -> console
656
- { name: "audit*", level: "trace", target: ["Json", "File"] }, // audit loggers -> json + file
657
- ],
658
- },
659
- };
660
- ```
661
-
662
- ## Flushing & shutdown
663
-
664
- Buffered targets (`FileTarget`, Loki, OTLP) hold entries in an in-memory
665
- `BatchQueue` and drain them on their own tick. To force a drain explicitly:
666
-
667
- - **`log.flush()`** — force-drains THIS logger's targets' buffers
668
- (`Promise<void>`). It calls `forceFlush()` on each target; on a non-buffered
669
- target that is a harmless no-op. `flush()` does **not** close or dispose the
670
- target — handle and timer teardown remains the DI container's job.
671
- - **`Log.flushAll()`** — flushes every registered logger (best-effort; never
672
- rejects). Static.
673
- - **`Log.clearLoggers()`** — flushes all loggers **before** disposing them, so
674
- buffered entries are written out during teardown rather than relying on the DI
675
- container disposing the target singletons.
676
-
677
- On a **clean** process exit the log bootstrapper registers a Node-only
678
- `beforeExit` hook that runs `Log.flushAll()`. This is best-effort: `beforeExit`
679
- does not fire on hard exits (`process.exit`, signals, crashes), so call
680
- `Log.flushAll()` / `Log.clearLoggers()` yourself in those paths.
681
-
682
- ## Extending
683
-
684
- **Custom target** — extend `LogTarget`, register it under a `type`, implement
685
- `write`. Optionally implement `forceFlush` (for buffered targets) and set
686
- `OnDropped` semantics (see the fallback contract):
687
-
688
- ```ts
689
- import { LogTarget, ICommonTargetOptions, ILogEntry } from "@spinajs/log-common";
690
- import { Injectable, Singleton } from "@spinajs/di";
691
- import { format } from "@spinajs/configuration-common";
692
-
693
- @Singleton()
694
- @Injectable("MyTarget")
695
- export class MyTarget extends LogTarget<ICommonTargetOptions> {
696
- public write(entry: ILogEntry): void {
697
- if (!this.Options.enabled) return;
698
- const line = format(entry.Variables, this.Options.layout);
699
- // …deliver `line`… ; reject/throw to signal non-acceptance (Retry/Fallback act on it)
700
- }
701
- }
702
- ```
703
-
704
- The `write()` contract: it **may reject** to signal the entry was not accepted —
705
- `RetryingTarget`/`FallbackGroupTarget` act on that. Self-healing targets resolve
706
- and call the optional `OnDropped(entry)` hook for entries they ultimately give
707
- up on, which `FallbackGroupTarget` chains to a durable fallback.
708
-
709
- **Custom filter** — extend `LogFilter`, register it under a `type`, implement
710
- `apply` (return the entry to keep, or `null` to drop):
711
-
712
- ```ts
713
- import { LogFilter, ILogEntry } from "@spinajs/log-common";
714
- import { Injectable } from "@spinajs/di";
715
-
716
- @Injectable("OnlyErrors")
717
- export class OnlyErrors extends LogFilter {
718
- public apply(entry: ILogEntry): ILogEntry | null {
719
- return entry.Level >= 5 /* Error */ ? entry : null;
720
- }
721
- }
722
- ```
723
-
724
- ## Package map
725
-
726
- ```
727
- your code ──> @spinajs/log ( Log service, targets, filters, rules, LogContext )
728
-
729
- low-level pkgs ──> @spinajs/internal-logger (buffers until config is ready,
730
- │ then replays into @spinajs/log)
731
-
732
- @spinajs/log-common (contracts: Log, LogTarget, LogFilter,
733
- BatchQueue, serializers, layout variables)
734
-
735
- network sinks: @spinajs/log-source-graphana-loki ( GraphanaLogTarget )
736
- @spinajs/log-otlp ( OtlpLogTarget )
737
- metrics: @spinajs/telemetry ( Prometheus + Apdex + timing )
738
- ```
739
-
740
- `InternalLogger` exists so packages that load **before** configuration/logging
741
- (DI, configuration) can still log. Those messages buffer and flush into the real
742
- targets once `Configuration` resolves; on process exit any still-buffered
743
- messages print to the console so nothing is lost. Do not use `InternalLogger` in
744
- application code — resolve `Log` instead.
1
+ # `@spinajs/log`
2
+
3
+ The logging system for SpinaJS. You resolve a `Log` service, call level methods
4
+ (`info`, `error`, …), and a **rules + targets** configuration decides which
5
+ logger writes **where** and in **what format**. It supports plain-text layouts
6
+ and structured JSON, buffered file logging with rotation/retention, network
7
+ sinks (Grafana Loki, OTLP), composable wrapper targets, a filter pipeline,
8
+ ambient request-scoped context with trace correlation, and runtime level
9
+ control.
10
+
11
+ ## Table of contents
12
+
13
+ - [Which package do I import?](#which-package-do-i-import)
14
+ - [Quick start](#quick-start)
15
+ - [Log levels](#log-levels)
16
+ - [Loggers](#loggers)
17
+ - [Logging API](#logging-api)
18
+ - [Layouts / templates](#layouts--templates)
19
+ - [Targets (sinks)](#targets-sinks)
20
+ - [Rules](#rules)
21
+ - [Filters](#filters)
22
+ - [Structured logging](#structured-logging)
23
+ - [Async context & correlation](#async-context--correlation)
24
+ - [File archiving](#file-archiving)
25
+ - [Runtime level control](#runtime-level-control)
26
+ - [Configuration reference](#configuration-reference)
27
+ - [Extending](#extending)
28
+ - [Package map](#package-map)
29
+
30
+ ## Which package do I import?
31
+
32
+ | Package | Use it for |
33
+ | --- | --- |
34
+ | `@spinajs/log` | **Application code.** Resolve `Log` and log. Ships the console/file/JSON/memory targets, the filter pipeline, `LogContext`, and runtime level control. |
35
+ | `@spinajs/log-common` | Only when writing a **custom target or filter** (abstract `LogTarget` / `LogFilter`, `ILogEntry`, `LogLevel`, `createLogMessageObject`, `serializeError`/`safeStringify`, `BatchQueue`). No implementation, so it avoids a circular dependency. |
36
+ | `@spinajs/internal-logger` | Only inside low-level packages (DI, configuration) that must log **before** the logger/config exists. Messages buffer and replay into the real logger once configuration resolves. |
37
+ | `@spinajs/log-source-graphana-loki` | The Grafana **Loki** target (`GraphanaLogTarget`). |
38
+ | `@spinajs/log-otlp` | The **OTLP/HTTP** export target (`OtlpLogTarget`) for any OpenTelemetry backend. |
39
+ | `@spinajs/telemetry` | Prometheus metrics + Apdex + an HTTP request-timing middleware. Metrics, not a log sink — see its own README. |
40
+
41
+ ## Quick start
42
+
43
+ ```ts
44
+ import { DI } from "@spinajs/di";
45
+ import { Log } from "@spinajs/log";
46
+
47
+ const log = await DI.resolve(Log, ["my-module"]); // logger named "my-module"
48
+
49
+ log.info("started");
50
+ log.info("user %s logged in", userId); // printf-style args
51
+ log.error(err, "could not connect to %s", host); // Error first, then message
52
+ log.info({ reqId, sku }, "checkout started"); // structured fields (merging object)
53
+ ```
54
+
55
+ With no configuration the logger writes every level to a colored console.
56
+
57
+ ## Log levels
58
+
59
+ Eight levels, lowest to highest severity:
60
+
61
+ ```
62
+ trace < debug < info < success < warn < error < fatal < security
63
+ ```
64
+
65
+ `success` and `security` are SpinaJS extras (a positive/notice level and a
66
+ top-of-scale audit level). A rule's `level` is the **minimum**; lower-severity
67
+ messages for that logger are dropped.
68
+
69
+ Each level also maps to the OpenTelemetry **SeverityNumber** (1–24), emitted by
70
+ the JSON/OTLP targets so any observability backend can rank/filter by severity:
71
+
72
+ | Level | SeverityNumber |
73
+ | --- | --- |
74
+ | trace | 1 |
75
+ | debug | 5 |
76
+ | info | 9 |
77
+ | success | 11 |
78
+ | warn | 13 |
79
+ | error | 17 |
80
+ | fatal | 21 |
81
+ | security | 23 |
82
+
83
+ ## Loggers
84
+
85
+ - **Named** — `DI.resolve(Log, ["name"])`. The same name returns the same logger.
86
+ - **`@Logger` decorator** — inject a logger into a class property:
87
+ ```ts
88
+ import { Logger, Log } from "@spinajs/log";
89
+ class UserService {
90
+ @Logger("UserService") protected Log: Log;
91
+ save() { this.Log.info("saving"); }
92
+ }
93
+ ```
94
+ - **Child loggers** — `const child = log.child("db", { pool: 1 });` creates a
95
+ logger named `parent.db` that inherits the parent's variables plus any extra.
96
+ - **Per-logger variables** — `log.addVariable("region", "eu");` makes `${region}`
97
+ available in that logger's layouts and structured records.
98
+ - **Timers** — `log.timeStart("q"); …; const ms = log.timeEnd("q");` returns the
99
+ elapsed milliseconds.
100
+
101
+ ## Logging API
102
+
103
+ Every level method (`trace`/`debug`/`info`/`warn`/`error`/`fatal`/`security`/`success`)
104
+ accepts three call shapes, dispatched by the **first argument**:
105
+
106
+ ```ts
107
+ log.info("plain message");
108
+ log.info("formatted %s = %d", name, count); // printf args
109
+ log.error(new Error("boom"), "while saving %s", id); // Error first -> structured `error`
110
+ log.info({ reqId: "abc", userId: 42 }, "handled"); // plain object first -> merged fields
111
+ ```
112
+
113
+ - **Error first** → the error is serialized into a structured `error` field and
114
+ the message/args follow.
115
+ - **Plain object first** (not an `Error`/array) → its keys are merged into the
116
+ entry's variables (available as `${key}` in layouts and as fields in JSON),
117
+ the second argument is the format string.
118
+ - **String first** → it is the message / printf format string.
119
+
120
+ **printf specifiers** (in the message string):
121
+
122
+ | Spec | Meaning |
123
+ | --- | --- |
124
+ | `%s` | string |
125
+ | `%d` | number |
126
+ | `%i` | `parseInt` |
127
+ | `%f` | `parseFloat` |
128
+ | `%j` / `%o` / `%O` | JSON / object |
129
+ | `%%` | literal `%` (consumes no argument) |
130
+
131
+ Leftover arguments are appended space-separated.
132
+
133
+ ## Layouts / templates
134
+
135
+ Text targets render each entry through a **layout** string using `${…}`
136
+ placeholders. The default layout is:
137
+
138
+ ```
139
+ ${datetime} ${level} ${message}${?error} Exception: ${error:message}${/error} (${logger})
140
+ ```
141
+
142
+ Available constructs:
143
+
144
+ | Construct | Renders |
145
+ | --- | --- |
146
+ | `${datetime}` | current date-time |
147
+ | `${date:dd_MM_yyyy}` | formatted date (Luxon-style format) |
148
+ | `${level}` | upper-case level, e.g. `ERROR` |
149
+ | `${message}` | the formatted message |
150
+ | `${logger}` | the logger name |
151
+ | `${myVar}` | any per-logger / merged / ambient variable |
152
+ | `${error:message}` | sub-property access on a variable (here the structured error's `message`) |
153
+ | `${?error} … ${/error}` | conditional block — rendered only when `error` is truthy |
154
+ | `${callsite}` | the caller's `file:line` (opt-in — see below) |
155
+
156
+ Set a target's `layout` to override the default, e.g.
157
+ `"${datetime} ${level} ${message} @ ${callsite} (${logger})"`.
158
+
159
+ **`${callsite}`** is captured only when *some* target's layout references it, so
160
+ logging stays zero-cost otherwise (no stack is walked). It resolves to
161
+ `basename:line` and is best-effort (empty string if the runtime stack can't be
162
+ parsed).
163
+
164
+ ## Targets (sinks)
165
+
166
+ A **target** is where messages go. Each configured target has a `name`
167
+ (referenced by rules) and a `type` (the DI key). Common options
168
+ (`ICommonTargetOptions`): `name`, `type`, `enabled` (default `true`), `layout`.
169
+
170
+ > **Per-target filters.** A target definition may carry its own `filters` list
171
+ > (same shape as `logger.filters`). These run **only** when writing to that
172
+ > target, **after** the logger-level pipeline, and drop/mutate the entry for
173
+ > **that target only** — the entry is cloned per target first, so a mutating
174
+ > filter (e.g. `WhenRepeatedFilter`'s `(xN)`) never bleeds into other targets:
175
+ >
176
+ > ```js
177
+ > targets: [
178
+ > { name: "Audit", type: "FileTarget",
179
+ > filters: [{ type: "MatchFilter", pattern: "secret", mode: "drop" }] },
180
+ > { name: "Console", type: "ConsoleTarget" }, // still sees everything
181
+ > ]
182
+ > ```
183
+
184
+ > **`enabled: false` targets are never instantiated.** A target definition
185
+ > marked `enabled: false` is skipped at resolve time — its class is never
186
+ > constructed, so a disabled `FileTarget` never opens its file, starts its flush
187
+ > timer, or spins up its archive service. A rule that references only disabled
188
+ > targets is silently skipped (treated as intentionally not routed); a rule that
189
+ > references a target **name that does not exist at all** still throws
190
+ > `InvalidOption`.
191
+
192
+ > **Config shape note:** some targets read their settings **flat** on the target
193
+ > definition (Console `theme`/`layout`, `MemoryTarget.limit`, `JsonTarget.stream`),
194
+ > while File/JSON-file and the wrapper targets read them **nested under an
195
+ > `options` object**. Loki and OTLP accept either. Each example below uses the
196
+ > form that target expects.
197
+
198
+ > **Runtime targets (attach/detach a sink).** Besides config, you can attach or
199
+ > detach a target on a specific logger **at runtime** (bunyan `addStream` style):
200
+ >
201
+ > ```js
202
+ > const log = DI.resolve(Log, ["my-logger"]);
203
+ >
204
+ > // attach – resolves the target ( honoring enabled:false ) and returns the instance
205
+ > const mem = log.addTarget(
206
+ > { name: "Live", type: "MemoryTarget" },
207
+ > { level: "warn" } // optional level window [level, maxLevel ?? security] + filters
208
+ > );
209
+ >
210
+ > // detach – flushes buffered entries, then removes it ( does NOT dispose it )
211
+ > await log.removeTarget("Live");
212
+ > ```
213
+ >
214
+ > `addTarget(def, opts?)` returns the resolved target instance (or `undefined`
215
+ > when `def.enabled === false`). `opts` accepts `level` / `maxLevel` (the level
216
+ > window) and `filters` (appended after `def.filters`). Adding a target whose
217
+ > `name` already exists **replaces** the previous one (flushed first), so a name
218
+ > is never duplicated, and the `MinLevel` / `${callsite}` gates are recomputed.
219
+ > `removeTarget(name)` force-flushes each matching target **before** detaching so
220
+ > no buffered entry is lost; it is a no-op when no target has that name.
221
+
222
+ | `type` | Class | Package | Writes to |
223
+ | --- | --- | --- | --- |
224
+ | `ConsoleTarget` | `ColoredConsoleTarget` / `BrowserConsoleTarget` | `@spinajs/log` | stdout/stderr (ANSI colors on Node; devtools styling in the browser) |
225
+ | `FileTarget` | `FileTarget` | `@spinajs/log` | a file via `@spinajs/fs`, buffered, with rotation/retention/zip |
226
+ | `JsonTarget` | `JsonTarget` | `@spinajs/log` | **stdout** as newline-delimited JSON (NDJSON) |
227
+ | `JsonFileTarget` | `JsonFileTarget` | `@spinajs/log` | a **file** as NDJSON (inherits FileTarget rotation) |
228
+ | `MemoryTarget` | `MemoryTarget` | `@spinajs/log` | an in-memory ring buffer (readable in-process) |
229
+ | `BlackHoleTarget` | `BlackHoleTarget` | `@spinajs/log` | nowhere (discards; useful in tests) |
230
+ | `SplitGroupTarget` | `SplitGroupTarget` | `@spinajs/log` | fans one target out to many |
231
+ | `AutoFlushTarget` | `AutoFlushTarget` | `@spinajs/log` | wraps a target; force-flushes it on high-severity entries |
232
+ | `RetryingTarget` | `RetryingTarget` | `@spinajs/log` | wraps a target; retries its `write` on rejection |
233
+ | `FallbackGroupTarget` | `FallbackGroupTarget` | `@spinajs/log` | ordered fallback across targets (+ drop-hook) |
234
+ | `GraphanaLogTarget` | `GraphanaLokiLogTarget` | `@spinajs/log-source-graphana-loki` | Grafana Loki over HTTP, batched |
235
+ | `OtlpLogTarget` | `OtlpLogTarget` | `@spinajs/log-otlp` | any OTLP/HTTP backend at `/v1/logs` |
236
+
237
+ ### Console
238
+
239
+ ```js
240
+ { name: "Console", type: "ConsoleTarget" }
241
+ ```
242
+
243
+ Node uses ANSI colors per level (override the palette with a `theme` map);
244
+ the browser build maps levels to `console.debug/log/warn/error`.
245
+
246
+ > **Browser caveat:** because output is formatted and dispatched through the
247
+ > logger, browser devtools attribute log lines to the console target, not your
248
+ > call site. Use `${callsite}` in the layout if you need the origin.
249
+
250
+ ### File
251
+
252
+ Writes through the [`@spinajs/fs`](../fs) abstraction, so the active log and its
253
+ archives can live on any registered provider (local disk, S3, FTP, …). Options
254
+ live under `options`:
255
+
256
+ ```js
257
+ {
258
+ name: "File",
259
+ type: "FileTarget",
260
+ options: {
261
+ path: "logs/log_${logger}_${date:dd_MM_yyyy}.txt", // required; variables allowed
262
+ archivePath: "logs/archive",
263
+ maxSize: 1024 * 1024, // rotate past this many bytes
264
+ compress: true, // zip archived files
265
+ maxBufferSize: 100, // buffered messages before a flush
266
+ maxQueueSize: 100000, // hard in-memory cap; drops oldest if a sink is stuck
267
+ flushInterval: 1000, // ms; flush a partial buffer at least this often
268
+ archiveStrategy: "SizeLogArchiveStrategy",
269
+ retentionStrategies: ["CountLogRetentionStrategy"],
270
+ maxArchiveFiles: 5,
271
+ maxAge: 7 * 24 * 60 * 60, // seconds
272
+ archiveInterval: 60, // seconds between size checks
273
+ },
274
+ }
275
+ ```
276
+
277
+ | option | default | meaning |
278
+ | --- | --- | --- |
279
+ | `path` | *required* | active log path, relative to the `fs` provider base path (variables allowed) |
280
+ | `archivePath` | log dir | archive directory, relative to the `archiveFs` provider |
281
+ | `fs` | `fs-log-default` | provider for the active log (`fs-log-default` is registered automatically at `process.cwd()`) |
282
+ | `archiveFs` | = `fs` | provider archives are moved to |
283
+ | `archiveStrategy` | `SizeLogArchiveStrategy` | rotation strategy class name |
284
+ | `retentionStrategies` | `["CountLogRetentionStrategy"]` | ordered retention strategy class names |
285
+ | `maxSize` | `1048576` | rotate when the active log exceeds this many bytes |
286
+ | `archiveInterval` | `60` | seconds between size checks |
287
+ | `rotate` | — | cron expression for `CronLogArchiveStrategy` (6-field, seconds supported) |
288
+ | `compress` | `false` | zip the archived file, then delete the raw copy |
289
+ | `maxBufferSize` | `100` | buffered messages before a flush |
290
+ | `maxQueueSize` | `100000` | hard cap; oldest buffered messages are dropped past it |
291
+ | `flushInterval` | `1000` | periodic flush tick in ms |
292
+ | `maxArchiveFiles` | `5` | archives to keep (`CountLogRetentionStrategy`) |
293
+ | `maxAge` | `604800` | max archive age in seconds (`AgeLogRetentionStrategy`) |
294
+
295
+ Writes are buffered and flushed as one batched `fs.append`, guarded by a
296
+ write-lock so a rotation never races an append; a failed append is retried
297
+ (never silently dropped, up to the `maxQueueSize` cap).
298
+
299
+ ### JSON (stdout) and JSON file
300
+
301
+ `JsonTarget` emits one JSON object per line to stdout — ideal for container log
302
+ collectors (promtail/Loki, Filebeat/Elastic, Datadog, CloudWatch) that index
303
+ fields instead of parsing text:
304
+
305
+ ```js
306
+ { name: "Json", type: "JsonTarget", stream: "stdout" } // or "stderr"
307
+ ```
308
+
309
+ A record looks like:
310
+
311
+ ```json
312
+ {"time":"2026-07-15T…","severityNumber":17,"level":"ERROR","logger":"checkout","message":"save failed","reqId":"abc","error":{"name":"Error","message":"save failed","stack":"…","code":"ECONNREFUSED"}}
313
+ ```
314
+
315
+ `JsonFileTarget` writes the same NDJSON to a **file**, reusing all of
316
+ FileTarget's rotation/retention/zip (configure it exactly like `FileTarget`
317
+ under `options`, with `type: "JsonFileTarget"`). Both stamp `time` at log time
318
+ and serialize with a never-throwing, circular-safe stringifier.
319
+
320
+ ### Memory (ring buffer)
321
+
322
+ Keeps the last `limit` entries in memory so a debug endpoint or a crash handler
323
+ can read recent context back in-process:
324
+
325
+ ```js
326
+ { name: "Memory", type: "MemoryTarget", limit: 200 } // default 100
327
+ ```
328
+
329
+ ```ts
330
+ const ring = DI.resolve<MemoryTarget>("MemoryTarget");
331
+ ring.getRecords(); // ILogEntry[] (newest last); ring.clear() to empty
332
+ ```
333
+
334
+ ### BlackHole
335
+
336
+ ```js
337
+ { name: "Null", type: "BlackHoleTarget" } // discards everything
338
+ ```
339
+
340
+ ### Wrapper targets
341
+
342
+ Wrappers decorate inner target definitions (given under `options`).
343
+
344
+ **SplitGroup** — fan one logical target out to many sinks:
345
+
346
+ ```js
347
+ { name: "Multi", type: "SplitGroupTarget", options: { targets: [
348
+ { name: "Console", type: "ConsoleTarget" },
349
+ { name: "File", type: "FileTarget", options: { path: "logs/app.log" } },
350
+ ]}}
351
+ ```
352
+
353
+ **AutoFlush** — force-flush an inner (buffered) target when a high-severity entry
354
+ arrives, so a crash-level event is never left buffered:
355
+
356
+ ```js
357
+ { name: "SafeFile", type: "AutoFlushTarget", options: {
358
+ target: { name: "File", type: "FileTarget", options: { path: "logs/app.log" } },
359
+ flushLevel: "error", // default "error"
360
+ }}
361
+ ```
362
+
363
+ **Retrying** — retry an inner target's `write` on rejection with exponential
364
+ backoff + jitter:
365
+
366
+ ```js
367
+ { name: "RetryOut", type: "RetryingTarget", options: {
368
+ target: { name: "Custom", type: "MyTarget" },
369
+ maxAttempts: 3, // default 3
370
+ delayMs: 100, // default 100
371
+ }}
372
+ ```
373
+
374
+ **FallbackGroup** — an ordered list; write advances to the next target when the
375
+ primary **rejects** (write-rejection contract), *and* a drop-hook chains entries
376
+ a self-healing network target **gives up on** (buffer overflow or a non-retryable
377
+ delivery failure) to the next target — a durable fallback for a down sink, with
378
+ no duplicates:
379
+
380
+ ```js
381
+ { name: "Durable", type: "FallbackGroupTarget", options: { targets: [
382
+ { name: "Otlp", type: "OtlpLogTarget", options: { endpoint: "http://collector:4318" } },
383
+ { name: "Spill", type: "JsonFileTarget", options: { path: "logs/undelivered.ndjson" } },
384
+ ]}}
385
+ ```
386
+
387
+ ### Grafana Loki (`@spinajs/log-source-graphana-loki`)
388
+
389
+ ```js
390
+ { name: "Loki", type: "GraphanaLogTarget", options: {
391
+ host: "http://localhost:3100",
392
+ auth: { username: "admin", password: "admin" }, // optional (unauthenticated Loki allowed)
393
+ labels: { app: "my-app" },
394
+ interval: 3000, bufferSize: 10, maxBufferSize: 1000, timeout: 1000,
395
+ }}
396
+ ```
397
+
398
+ Batched HTTP push with exponential-backoff + jitter retry (honoring
399
+ `Retry-After`, retrying only network errors and 429/502/503/504); non-retryable
400
+ errors surface instead of looping. The primary buffer is bounded.
401
+
402
+ ### OTLP (`@spinajs/log-otlp`)
403
+
404
+ Export to any OpenTelemetry backend (OTel Collector, Grafana/Tempo, Datadog, …):
405
+
406
+ ```js
407
+ { name: "Otlp", type: "OtlpLogTarget", options: {
408
+ endpoint: "http://localhost:4318", // POSTs to /v1/logs
409
+ headers: { Authorization: "Bearer …" }, // optional
410
+ resource: { "service.name": "my-app" }, // resource attributes
411
+ scopeName: "@spinajs/log",
412
+ interval: 3000, bufferSize: 10, maxBufferSize: 1000, timeout: 5000,
413
+ }}
414
+ ```
415
+
416
+ Maps each entry to the OTLP Logs model — `severityNumber`, `body`, resource +
417
+ record attributes, `traceId`/`spanId` from the request trace context, and a
418
+ structured `error` to `exception.type`/`exception.message`/`exception.stacktrace`
419
+ semantic attributes. Batched with the same resilience retry as Loki.
420
+
421
+ ## Rules
422
+
423
+ A **rule** binds a logger-name pattern to a minimum `level` and one or more
424
+ `target` names:
425
+
426
+ ```js
427
+ { name: "http/*/controller", level: "info", target: ["Console", "File"] }
428
+ ```
429
+
430
+ ### Level windows (`maxLevel`)
431
+
432
+ `level` is the **lower** bound. Add an optional `maxLevel` to route only a level
433
+ **window** `[level, maxLevel]` (inclusive) — e.g. warn/error but **not**
434
+ fatal/security:
435
+
436
+ ```js
437
+ { name: "*", level: "warn", maxLevel: "error", target: "Ops" }
438
+ ```
439
+
440
+ Without `maxLevel` the upper bound defaults to the highest level (`security`), so
441
+ a plain min-only rule is unchanged.
442
+
443
+ Several rules may route to the **same** target with **different** windows; the
444
+ target then accepts the **union** of those windows. So two rules `info..info` and
445
+ `error..error` to one target deliver `info` and `error` but **not** a `warn`
446
+ between them.
447
+
448
+ > `maxLevel` does **not** lower the per-logger `MinLevel` fast-gate: a call above
449
+ > every window still builds the entry and is then filtered out per target — the
450
+ > gate only tracks the lowest `level` across rules.
451
+
452
+ Name matching uses glob semantics:
453
+
454
+ - `*` — any logger name.
455
+ - `prefix*` — names starting with `prefix`.
456
+ - `a.b.*` — dotted namespaces.
457
+ - an exact name matches only itself.
458
+
459
+ ### Ordered, additive matching (`final`)
460
+
461
+ Rules are evaluated **in config order**, and matching is **additive** (NLog-style):
462
+ **every** rule whose pattern matches a logger applies, so a logger matched by both
463
+ `*` and a specific rule routes to **both** (targets are de-duped downstream, so a
464
+ target hit by two matching rules still receives each entry once).
465
+
466
+ A matched rule marked `final: true` **stops** evaluation of any *later* rules; that
467
+ final rule and all earlier matched rules still apply.
468
+
469
+ ```js
470
+ rules: [
471
+ { name: "db.pool", level: "trace", target: "PoolDebug", final: true }, // stops here
472
+ { name: "*", level: "info", target: "Console" }, // skipped for db.pool
473
+ ]
474
+ ```
475
+
476
+ - `db.pool` matches the first rule, applies it, and stops — the later `*` is **not**
477
+ applied, so `db.pool` routes **only** to `PoolDebug`.
478
+ - any other logger doesn't match `db.pool`, falls through, and routes to `Console`.
479
+
480
+ > **Migration from the old behavior.** Previously a specific rule *dropped* the `*`
481
+ > catch-all, so adding a rule for one logger silently **excluded** it from the
482
+ > global console/file. Now the specific rule is **additive** — that logger reaches
483
+ > both its own target **and** the catch-all. To restore the old "this logger goes
484
+ > **only** here" behavior, mark its rule `final: true` and place it **before** the
485
+ > `*` catch-all (as above).
486
+
487
+ ## Filters
488
+
489
+ Filters run in order per logger and can drop or modify entries. Configure a list
490
+ under `logger.filters`; each item's `type` is a DI-registered filter. A filter
491
+ returns the (possibly modified) entry to keep, or drops it.
492
+
493
+ ```js
494
+ logger: {
495
+ filters: [
496
+ { type: "LevelFilter", min: "warn" },
497
+ { type: "MatchFilter", pattern: "healthcheck", mode: "drop" },
498
+ { type: "RateLimitFilter", limit: 100, intervalSeconds: 10 },
499
+ { type: "WhenRepeatedFilter", timeout: 10 },
500
+ ],
501
+ // …targets, rules
502
+ }
503
+ ```
504
+
505
+ | Filter | Options | Effect |
506
+ | --- | --- | --- |
507
+ | `WhenRepeatedFilter` | `timeout` (s, default 10), `maxKeys` (default 1024) | collapses identical repeated entries within the window into one, appending `(xN)` when logging resumes |
508
+ | `LevelFilter` | `min`, `max` (level names) | keeps only entries whose level is within `[min, max]` |
509
+ | `MatchFilter` | `pattern`, `field` (default `message`), `mode` (`keep`/`drop`, default `keep`), `flags` | regex-match a variable; keep on match (or drop, in `drop` mode); an invalid pattern is a no-op |
510
+ | `RateLimitFilter` | `limit`, `intervalSeconds`, `key` (optional variable) | fixed-window rate limit; drops overflow, per-key or global |
511
+
512
+ Filters run **after** the near-zero-cost level gate, so disabled levels never
513
+ reach them. The legacy `logger.whenRepeated` option still works (mapped to a
514
+ prepended `WhenRepeatedFilter`).
515
+
516
+ The same filter list can also be attached **per target** (`targets[].filters`) to
517
+ filter for one sink only — see [Targets](#targets-sinks). Per-target filters run
518
+ **after** the logger-level pipeline on a per-target clone, so a filter that mutates
519
+ the entry there never affects other targets.
520
+
521
+ ## Structured logging
522
+
523
+ Use `JsonTarget`/`JsonFileTarget` (or Loki/OTLP) to emit machine-readable
524
+ records. The pieces:
525
+
526
+ - **Serializer registry** — registered field serializers run when an entry is
527
+ built. The default `error` serializer turns an `Error` into
528
+ `{ name, message, stack, code, signal }`, walking the `.cause` /
529
+ `AggregateError` chain into `stack`. Register your own:
530
+ ```ts
531
+ import { registerSerializer } from "@spinajs/log-common";
532
+ registerSerializer("req", (r: any) => ({ method: r.method, url: r.url }));
533
+ // then: log.info({ req }, "handled")
534
+ ```
535
+ A serializer that throws degrades to `{ serializerError }` — logging never
536
+ crashes the caller.
537
+ - **Merging-object fields** — `log.info({ reqId, sku }, "…")` adds `reqId`/`sku`
538
+ as first-class fields.
539
+ - **`safeStringify`** — the JSON targets serialize with a never-throwing,
540
+ `[Circular]`-safe stringifier, so a circular field can't break logging.
541
+ - **`severityNumber`** — the OTel severity number is included on JSON/OTLP
542
+ records for backend severity ranking.
543
+
544
+ ## Async context & correlation
545
+
546
+ `LogContext` provides ambient, per-operation variables over an
547
+ `AsyncLocalStorage` shared with `@spinajs/http` — so anything logged inside a
548
+ request automatically carries its context with zero threading.
549
+
550
+ ```ts
551
+ import { LogContext } from "@spinajs/log";
552
+
553
+ LogContext.with({ requestId: "abc", tenant: "acme" }, async () => {
554
+ // any logger, any depth, across awaits:
555
+ log.info("deep inside"); // entry carries requestId + tenant
556
+ });
557
+ ```
558
+
559
+ - `LogContext.with(vars, fn)` — run `fn` with `vars` merged onto the current
560
+ context (copy-on-write; nesting accumulates).
561
+ - `LogContext.active()` — the current context (or `{}`).
562
+ - `LogContext.set(key, value)` — late-bind a value onto the active context.
563
+ - `LogContext.bind(fn)` — capture the context and re-attach it to a detached
564
+ callback / event handler.
565
+
566
+ Only **scalar** values (string/number/boolean/bigint) from the ambient context
567
+ are projected into log lines — objects/arrays/Dates are skipped as noise (pass
568
+ structured payloads explicitly per call). Inside an HTTP request the context is
569
+ `req.storage`, so logs automatically carry `requestId` and `realIp`.
570
+
571
+ **Trace correlation** — the http `RequestId` middleware continues an inbound W3C
572
+ `traceparent` (or starts a new trace) and seeds `traceId`/`spanId` into the
573
+ context, so every log line across services shares a trace id (and they surface as
574
+ top-level fields on OTLP records). Helpers `parseTraceparent`,
575
+ `formatTraceparent`, and `newTraceContext` are exported for custom propagation.
576
+
577
+ ## File archiving
578
+
579
+ `FileTarget`/`JsonFileTarget` rotate and prune via strategies selected by class
580
+ name:
581
+
582
+ **Rotation** (when to archive) — one strategy:
583
+
584
+ - `SizeLogArchiveStrategy` — interval timer; rotates when the active log passes `maxSize`.
585
+ - `CronLogArchiveStrategy` — rotates on the `rotate` cron expression (6-field, seconds supported).
586
+
587
+ **Retention** (which archives to delete) — an ordered list, so policies compose:
588
+
589
+ - `CountLogRetentionStrategy` — keep the newest `maxArchiveFiles`.
590
+ - `AgeLogRetentionStrategy` — delete archives older than `maxAge` seconds.
591
+
592
+ ```js
593
+ { name: "File", type: "FileTarget", options: {
594
+ path: "logs/app.log",
595
+ rotate: "0 0 1 * * *", // 1am daily
596
+ archiveStrategy: "CronLogArchiveStrategy",
597
+ retentionStrategies: ["CountLogRetentionStrategy", "AgeLogRetentionStrategy"],
598
+ maxArchiveFiles: 5,
599
+ maxAge: 7 * 24 * 60 * 60,
600
+ compress: true,
601
+ }}
602
+ ```
603
+
604
+ Custom strategies extend `LogArchiveStrategy` / `LogRetentionStrategy`, register
605
+ in DI, and are named in the config. The browser build omits `FileTarget` and the
606
+ archive module (and never pulls in `@spinajs/fs`).
607
+
608
+ ## Runtime level control
609
+
610
+ Every logger supports a runtime override on top of its rule-derived minimum
611
+ level, with a near-zero-cost disabled path (a disabled call returns before
612
+ building an entry):
613
+
614
+ ```ts
615
+ log.getLevel(); // current effective LogLevel
616
+ log.setLevel("error"); // gate everything below error (persists in the browser)
617
+ log.setDefaultLevel("info"); // set only if nothing is already overridden/persisted
618
+ log.enableAll(); // = setLevel("trace")
619
+ log.disableAll(); // silence everything
620
+ log.resetLevel(); // back to the rule-derived level
621
+ ```
622
+
623
+ In the browser the chosen level persists to `localStorage` (cookie fallback), so
624
+ it survives reloads; on Node persistence is a no-op. `setLevel` accepts a level
625
+ name or a `LogLevel` value (validated via `normalizeLevel`).
626
+
627
+ ## Configuration reference
628
+
629
+ A complete `logger` configuration, validated against the schema in
630
+ `src/schemas/log.configuration.ts` (`targets` and `rules` are required non-empty
631
+ arrays; a target needs `name` + `type`; a rule needs `name` + `level` + `target`):
632
+
633
+ ```js
634
+ module.exports = {
635
+ logger: {
636
+ variables: {},
637
+ targets: [
638
+ { name: "Console", type: "ConsoleTarget" },
639
+ { name: "Json", type: "JsonTarget", stream: "stdout" },
640
+ { name: "File", type: "FileTarget", options: {
641
+ path: "logs/log_${logger}_${date:dd_MM_yyyy}.txt",
642
+ archivePath: "logs/archive",
643
+ maxSize: 1024 * 1024,
644
+ compress: true,
645
+ maxBufferSize: 8 * 1024,
646
+ retentionStrategies: ["CountLogRetentionStrategy", "AgeLogRetentionStrategy"],
647
+ maxArchiveFiles: 5,
648
+ maxAge: 7 * 24 * 60 * 60,
649
+ }},
650
+ ],
651
+ filters: [
652
+ { type: "WhenRepeatedFilter", timeout: 10 },
653
+ ],
654
+ rules: [
655
+ { name: "*", level: "info", target: "Console" }, // everything -> console
656
+ { name: "audit*", level: "trace", target: ["Json", "File"] }, // audit loggers -> json + file
657
+ ],
658
+ },
659
+ };
660
+ ```
661
+
662
+ ## Flushing & shutdown
663
+
664
+ Buffered targets (`FileTarget`, Loki, OTLP) hold entries in an in-memory
665
+ `BatchQueue` and drain them on their own tick. To force a drain explicitly:
666
+
667
+ - **`log.flush()`** — force-drains THIS logger's targets' buffers
668
+ (`Promise<void>`). It calls `forceFlush()` on each target; on a non-buffered
669
+ target that is a harmless no-op. `flush()` does **not** close or dispose the
670
+ target — handle and timer teardown remains the DI container's job.
671
+ - **`Log.flushAll()`** — flushes every registered logger (best-effort; never
672
+ rejects). Static.
673
+ - **`Log.clearLoggers()`** — flushes all loggers **before** disposing them, so
674
+ buffered entries are written out during teardown rather than relying on the DI
675
+ container disposing the target singletons.
676
+
677
+ On a **clean** process exit the log bootstrapper registers a Node-only
678
+ `beforeExit` hook that runs `Log.flushAll()`. This is best-effort: `beforeExit`
679
+ does not fire on hard exits (`process.exit`, signals, crashes), so call
680
+ `Log.flushAll()` / `Log.clearLoggers()` yourself in those paths.
681
+
682
+ ## Extending
683
+
684
+ **Custom target** — extend `LogTarget`, register it under a `type`, implement
685
+ `write`. Optionally implement `forceFlush` (for buffered targets) and set
686
+ `OnDropped` semantics (see the fallback contract):
687
+
688
+ ```ts
689
+ import { LogTarget, ICommonTargetOptions, ILogEntry } from "@spinajs/log-common";
690
+ import { Injectable, Singleton } from "@spinajs/di";
691
+ import { format } from "@spinajs/configuration-common";
692
+
693
+ @Singleton()
694
+ @Injectable("MyTarget")
695
+ export class MyTarget extends LogTarget<ICommonTargetOptions> {
696
+ public write(entry: ILogEntry): void {
697
+ if (!this.Options.enabled) return;
698
+ const line = format(entry.Variables, this.Options.layout);
699
+ // …deliver `line`… ; reject/throw to signal non-acceptance (Retry/Fallback act on it)
700
+ }
701
+ }
702
+ ```
703
+
704
+ The `write()` contract: it **may reject** to signal the entry was not accepted —
705
+ `RetryingTarget`/`FallbackGroupTarget` act on that. Self-healing targets resolve
706
+ and call the optional `OnDropped(entry)` hook for entries they ultimately give
707
+ up on, which `FallbackGroupTarget` chains to a durable fallback.
708
+
709
+ **Custom filter** — extend `LogFilter`, register it under a `type`, implement
710
+ `apply` (return the entry to keep, or `null` to drop):
711
+
712
+ ```ts
713
+ import { LogFilter, ILogEntry } from "@spinajs/log-common";
714
+ import { Injectable } from "@spinajs/di";
715
+
716
+ @Injectable("OnlyErrors")
717
+ export class OnlyErrors extends LogFilter {
718
+ public apply(entry: ILogEntry): ILogEntry | null {
719
+ return entry.Level >= 5 /* Error */ ? entry : null;
720
+ }
721
+ }
722
+ ```
723
+
724
+ ## Package map
725
+
726
+ ```
727
+ your code ──> @spinajs/log ( Log service, targets, filters, rules, LogContext )
728
+
729
+ low-level pkgs ──> @spinajs/internal-logger (buffers until config is ready,
730
+ │ then replays into @spinajs/log)
731
+
732
+ @spinajs/log-common (contracts: Log, LogTarget, LogFilter,
733
+ BatchQueue, serializers, layout variables)
734
+
735
+ network sinks: @spinajs/log-source-graphana-loki ( GraphanaLogTarget )
736
+ @spinajs/log-otlp ( OtlpLogTarget )
737
+ metrics: @spinajs/telemetry ( Prometheus + Apdex + timing )
738
+ ```
739
+
740
+ `InternalLogger` exists so packages that load **before** configuration/logging
741
+ (DI, configuration) can still log. Those messages buffer and flush into the real
742
+ targets once `Configuration` resolves; on process exit any still-buffered
743
+ messages print to the console so nothing is lost. Do not use `InternalLogger` in
744
+ application code — resolve `Log` instead.