logroller 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,382 +1,406 @@
1
- <p align="center"><strong>logroller</strong></p>
2
- <h3 align="center">Zero-dependency rotating file streams for Node.js</h3>
3
-
4
- <div align="center">
5
-
6
- ![npm](https://img.shields.io/npm/v/logroller)
7
- ![node](https://img.shields.io/node/v/logroller)
8
- ![license](https://img.shields.io/npm/l/logroller)
9
- ![types](https://shields.io/badge/types-included-blue)
10
-
11
- </div>
12
-
13
- ---
14
-
15
- `logroller` turns a plain append destination into a resilient, observable log
16
- sink: **it is a Writable**, so `readable.pipe(log)` works, pino multistreams
17
- work, anything that consumes streams works.
18
-
19
- ```bash
20
- npm install logroller
21
- ```
22
-
23
- ## Features
24
-
25
- - **Daily & interval rotation** — exact local midnight in any IANA zone,
26
- or epoch-aligned intervals (`30s`, `5m`, `2h`)
27
- - **Size caps** — `"20m"`-style limits roll numbered segments
28
- - **Gzip archives** — sealed segments compressed atomically, level 0–9
29
- - **Stable "current" path** — a symlink log collectors can follow
30
- (`tail -F app-current.log`, Filebeat, promtail) while files rotate beneath
31
- - **Retention three ways** — by age, by count, **or by total byte budget**
32
- ("never exceed 5 GB of archives")
33
- - **Upload pipeline** — an awaited `onSeal` hook that fires after a segment
34
- is archived and _before_ retention can delete it: ship to S3/GCS safely
35
- - **Graceful shutdown** — one helper flushes buffered writes on
36
- SIGINT/SIGTERM (no more missing last lines)
37
- - **Timezone-correct** — `Asia/Kolkata` means IST filenames, on a UTC box
38
- - **Crash-safe** — restarts resume mid-series byte-exactly; gzips
39
- interrupted by a kill are completed on boot
40
- - **Observability** — `<stem>_audit.json` manifest with a uuid-tagged event
41
- journal, plus `stats()` / `listSegments()` introspection
42
- - **Testable time** — virtual clocks (`addHours`, `addHoursEveryMin`,
43
- `advanceClock()`) let a day pass per minute, or instantly
44
- - **Types first** — tuple-typed events, strict mode, dual ESM/CJS build
45
- - **Zero dependencies**
46
-
47
- ## Quick start
48
-
49
- ### ESM
50
-
51
- ```ts
52
- import { createStream } from "logroller";
53
-
54
- const log = createStream({
55
- filename: "logs/app-%DATE%.log",
56
- datePattern: "YYYY-MM-DD",
57
- zippedArchive: true,
58
- maxSize: "20m",
59
- maxFiles: "90d",
60
- tz: "UTC",
61
- frequency: "daily",
62
- });
63
-
64
- log.write("hello\n");
65
- log.on("rotated", ({ reason, archive, newFile }) =>
66
- console.log(`${reason}: ${archive ?? "-"} → ${newFile}`),
67
- );
68
- process.on("exit", () => log.end());
69
- ```
70
-
71
- ### Production shape — collectors, budget, uploads, shutdown
72
-
73
- ```ts
74
- import { createStream, installShutdown } from "logroller";
75
-
76
- const log = createStream({
77
- filename: "logs/app-%DATE%.log",
78
- zippedArchive: true,
79
- maxSize: "20m",
80
- maxTotalSize: "5g", // never hold more than 5 GB of archives
81
- maxFiles: "90d",
82
- symlink: true, // logs/app-current.log always points at the live file
83
- onSeal: async ({ gz }) => uploadToS3(gz), // awaited BEFORE retention deletes
84
- });
85
-
86
- // SIGINT/SIGTERM → flush buffered writes, then exit
87
- installShutdown(log);
88
- ```
89
-
90
- ### CommonJS
91
-
92
- ```js
93
- const { createStream } = require("logroller");
94
- ```
95
-
96
- ### pino destination
97
-
98
- ```js
99
- const logger = pino(
100
- pino.multistream([
101
- { level: "info", stream: createStream({ filename: "logs/general-%DATE%.log" }) },
102
- { level: "error", stream: createStream({ filename: "logs/error-%DATE%.log" }) },
103
- ]),
104
- );
105
- ```
106
-
107
- ## What lands on disk
108
-
109
- Example snapshot for `filename: "logs/app-%DATE%.log"` on **Aug 28**, with
110
- `zippedArchive: true` and `symlink: true`:
111
-
112
- ```
113
- app-current.log ← symlink → app-2026-08-28.log (only with symlink: true)
114
- app-2026-08-27.log.gz ← yesterday, sealed (hit 20 MB)
115
- app-2026-08-27.1.log.gz ← yesterday, sealed too
116
- app-2026-08-28.log ← ACTIVE — the only raw file on disk
117
- app_audit.json ← library-owned manifest
118
- ```
119
-
120
- Three invariants that hold at every moment:
121
-
122
- 1. **Exactly one raw file** — the active segment. Everything else is a
123
- `.gz` archive (or a raw tail kept only because gzip failed — with a
124
- `warn` event, never silently).
125
- 2. **A segment's `.log` and `.log.gz` never coexist.** The raw file is
126
- deleted only _after_ its archive is verified.
127
- 3. **`app-current.log` always points at the active segment**, flipped
128
- atomically on every open. It exists only when `symlink` is enabled, and
129
- retention never touches it.
130
-
131
- ### How a day evolves (all states, in order)
132
-
133
- | Moment (tz) | Directory contains | `app-current.log` → |
134
- | ------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
135
- | morning, first write | `…27.log` | `…27.log` |
136
- | hit 20 MB → roll | `…27.log.gz` + `…27.1.log` | `…27.1.log` |
137
- | `.1` fills → roll | `…27.log.gz` + `…27.1.log.gz` | _(stale until next write — segments open lazily on first write)_ |
138
- | midnight → Aug 28 + write | `…27.log.gz` + `…27.1.log.gz` + `…28.log` | `…28.log` |
139
-
140
- A killed process resumes without gaps or duplicates:
141
-
142
- | Disk at crash | Restart continues into |
143
- | ------------------------------- | ------------------------------------- |
144
- | `app-D.log` | `app-D.log` (append) |
145
- | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
146
- | `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
147
- | died during a gzip | boot finishes the gzip, then proceeds |
148
- | crash left yesterday's tail raw | boot seals (gzips) it automatically |
149
-
150
- ## Log collectors & the stable current path
151
-
152
- Rotating filenames are great for humans, hostile to consumers: `tail -f`,
153
- Filebeat, Fluent Bit and promtail want **one path that never changes**.
154
-
155
- ```ts
156
- const log = createStream({
157
- filename: "logs/app-%DATE%.log",
158
- symlink: true, // → logs/app-current.log
159
- // symlink: "CURRENT", // → logs/CURRENT (custom name)
160
- });
161
- ```
162
-
163
- ```bash
164
- tail -F logs/app-current.log # survives every rotation (capital -F: re-resolve the link)
165
- ```
166
-
167
- - The pointer is flipped **atomically** (tmp + rename) on every segment open,
168
- so followers never observe a gap or a half-written name.
169
- - The target is stored **relative**, so the directory stays portable.
170
- - Best-effort by design: on filesystems that deny symlinks (Windows without
171
- Developer Mode) the stream warns once and continues without it.
172
- - The pointer is never touched by retention and never reported as a segment.
173
-
174
- ## Upload pipeline — archive, ship, _then_ delete
175
-
176
- `onSeal` runs inside the rotation queue **after** the segment is compressed
177
- and **before** retention can remove it — the safe window for shipping:
178
-
179
- ```ts
180
- const log = createStream({
181
- filename: "logs/cdr-%DATE%.log",
182
- zippedArchive: true,
183
- maxFiles: "7d", // local copies are disposable
184
- onSeal: async ({ raw, gz }) => {
185
- await uploadToS3(gz ?? raw); // awaited — rotation waits
186
- },
187
- sealHookTimeoutMs: 60_000, // a wedged upload warns instead of stalling
188
- });
189
- ```
190
-
191
- Guarantees:
192
-
193
- - invoked exactly once per sealed segment, with `{ raw, gz }` (`gz: null`
194
- when compression is off or gzip failed)
195
- - **ordering**: hook → then retention sweep, so an in-flight upload can never
196
- race a deletion
197
- - a throwing or timed-out hook degrades to a `warn` event — logging and
198
- rotation continue
199
-
200
- ## Graceful shutdown
201
-
202
- The classic support case: SIGTERM arrives, buffered lines are lost. One call
203
- ends it:
204
-
205
- ```ts
206
- import { createStream, installShutdown } from "logroller";
207
-
208
- const log = createStream({ filename: "logs/app-%DATE%.log" });
209
- installShutdown(log); // SIGINT + SIGTERM → end() → exit 0
210
- ```
211
-
212
- - `installShutdown(stream | streams, { signals?, timeoutMs?, code? })` —
213
- installs process listeners once; streams accumulate in a registry.
214
- - On signal: every registered stream is ended (buffered writes flushed,
215
- audit manifest finalized), with a hard `timeoutMs` deadline (default 5 s).
216
- - `flushAll()` is the same graceful end **without exiting** — for tests and
217
- custom signal handling.
218
-
219
- ## Introspection
220
-
221
- ```ts
222
- const st = log.stats();
223
- // { dir, currentStamp, activeFile, activeIndex, segments, archived,
224
- // totalBytes, clockOffsetMs, symlink }
225
-
226
- for (const seg of log.listSegments()) {
227
- // { file, stamp, index, gzipped, bytes, state, openedAt?, archivedAt? }
228
- console.log(seg.stamp, seg.index, seg.gzipped ? "gz" : "raw", seg.bytes);
229
- }
230
- ```
231
-
232
- `listSegments()` reads the **directory** (disk is truth), so it also reports
233
- files the manifest doesn't know (state `"untracked"`). Perfect for
234
- `/healthz` endpoints, dashboards, and "where did my logs go" support.
235
-
236
- ## Retention — three policies, composable
237
-
238
- ```ts
239
- maxFiles: "90d" // age: delete archives older than 90 days
240
- maxFiles: 500 // count: keep the newest 500 archives
241
- maxTotalSize: "5g" // budget: delete OLDEST archives until under 5 GB
242
- maxFiles: "30d",
243
- maxTotalSize: "2g", // combined: either policy may delete
244
- ```
245
-
246
- Unset means files live forever until you delete them. The active segment is
247
- never a retention candidate, and the audit/symlink files are always reserved.
248
-
249
- ## API
250
-
251
- ### `createStream(options)` → `RotateFileStream`
252
-
253
- ### Options
254
-
255
- | Option | Values | Default |
256
- | --------------------- | ------------------------------------- | ------------------ |
257
- | `filename` | **required**, must contain `%DATE%` | — |
258
- | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
259
- | `zippedArchive` | boolean | `false` |
260
- | `compressionLevel` | gzip level `0`–`9` (0 = stored) | `6` |
261
- | `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
262
- | `maxFiles` | `"90d"` age, `500` count, unset=never | — |
263
- | `maxTotalSize` | `"5g"`, `"500m"`, bytes, `0`=off | off |
264
- | `symlink` | `true` (auto name) or custom string | off |
265
- | `onSeal` | `async ({ raw, gz }) => void` | — |
266
- | `sealHookTimeoutMs` | hard deadline for one `onSeal` call | `30000` |
267
- | `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
268
- | `tz` | any IANA zone | `UTC` |
269
- | `audit` / `auditFile` | manifest control | `true` / auto-name |
270
- | `unrefTimers` | detach from event loop | `false` |
271
- | `addHours` | ⚠️ static clock shift, test only | off |
272
- | `addHoursEveryMin` | ⚠️ virtual time per tick, test only | off |
273
- | `clockStepIntervalMs` | tick interval | `60000` |
274
-
275
- ### Methods
276
-
277
- | Method | Returns | Purpose |
278
- | ----------------------- | ---------------- | ------------------------------- |
279
- | `write / end / destroy` | stream semantics | normal `Writable` |
280
- | `rotateNow()` | `Promise<void>` | force a rotation |
281
- | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
282
- | `stats()` | `StreamStats` | health snapshot |
283
- | `listSegments()` | `SegmentInfo[]` | every family file, oldest first |
284
-
285
- ### Module functions
286
-
287
- | Function | Purpose |
288
- | --------------------------------- | ------------------------------------------------------------ |
289
- | `installShutdown(streams, opts?)` | register streams for SIGINT/SIGTERM flush; returns `flush()` |
290
- | `flushAll(timeoutMs?)` | gracefully end all registered streams, no exit |
291
-
292
- ### Events (typed)
293
-
294
- | Event | Payload | Meaning |
295
- | --------- | ------------------------------------- | ------------------------------------------------------------ |
296
- | `open` | `file` | segment opened for appending |
297
- | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
298
- | `archive` | `gzFile` | gzip finished |
299
- | `deleted` | `file` | retention removed it |
300
- | `period` | `stamp` | boundary crossed while idle |
301
- | `clock` | `{offsetMs, …}` | virtual clock engaged |
302
- | `warn` | `Error` | non-fatal (hook failure, gzip retry, symlink unavailable, …) |
303
- | `error` | `Error` | fatal write problems |
304
-
305
- Errors must be handled — attach a listener on every instance.
306
-
307
- ## Testing rotation without waiting a week
308
-
309
- ```ts
310
- const s = createStream({
311
- filename: "logs/demo-%DATE%.log",
312
- zippedArchive: true,
313
- addHours: "-48h", // start two days back
314
- addHoursEveryMin: "24h", // one virtual day per real minute
315
- clockStepIntervalMs: 1000, // or faster
316
- });
317
- await s.advanceClock(); // jump instantly when useful
318
- ```
319
-
320
- Negative `addHoursEveryMin` deliberately triggers the backward-clock guard
321
- (warn, no rotation) — it exists so you can test that safety net too.
322
-
323
- ## FAQ
324
-
325
- **Why is `%DATE%` mandatory?**
326
- It is the anchor that decouples _rotation scheduling_ from _filenames_.
327
- Coarser patterns than your frequency collapse distinct segments onto one
328
- name — match tokens to cadence (`HH` for hourly, etc.). The library warns at
329
- construction when it detects a mismatch.
330
-
331
- **How do I ship logs to S3 without losing any?**
332
- `zippedArchive: true` + `onSeal` (see the upload pipeline section). The hook
333
- is awaited and runs before retention, so an in-flight upload can never race
334
- a deletion.
335
-
336
- **How do I keep `tail -f` working across rotations?**
337
- `symlink: true` — follow `app-current.log`. Use `tail -F` (capital) so the
338
- follower re-resolves the link after it flips.
339
-
340
- **Can two processes share one directory?**
341
- Different prefixes/families: yes. The _same_ family: no — the audit manifest
342
- detects a live second writer (`writer.heartbeat`) and warns loudly.
343
-
344
- **Is the audit JSON required?**
345
- No — `audit: false` reduces behaviour to pure disk-scan recovery. Recommended
346
- to leave on: it repairs interrupted gzips, demotes zombie records, and
347
- powers `listSegments()` states.
348
-
349
- **What if the system clock jumps backward?**
350
- The stream keeps the current segment, warns once, and resumes normal
351
- rotation when the clock catches up — the series is never forked into a past
352
- family. (Requires zero-padded `datePattern` tokens, which all presets are.)
353
-
354
- **Windows / macOS / Linux?**
355
- Pure Node APIs; path handling via `node:path`. All three supported
356
- (Node `>=16.14`). Symlinks are best-effort on Windows — see above.
357
-
358
- ## Compare
359
-
360
- | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
361
- | --------------------------------- | ----------------- | ------------------- | -------------------- | ------------------------- |
362
- | Dependencies | **0** | 4+ | 2+ | winston-bound |
363
- | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
364
- | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
365
- | Event journal | ✅ | partial | ✖ | ✖ |
366
- | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
367
- | Stable current path | ✅ atomic | ✅ | ✅ | ✅ |
368
- | Byte-budget retention | ✅ | ✅ (bytes only) | ✖ | ✖ |
369
- | Awaited upload-before-delete hook | ✅ | ✖ | ✖ | ✖ |
370
- | Graceful shutdown helper | ✅ | ✖ | ✖ | via winston |
371
- | Introspection API | ✅ | ✖ | ✖ | partial |
372
- | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
373
- | Winston required | no | no | no | yes |
374
-
375
- ## Contributing
376
-
377
- PRs welcome. `npm run verify` gates every change (lint + typecheck + tests).
378
- Please file issues before large refactors.
379
-
380
- ## License
381
-
382
- [MIT](./LICENSE)
1
+ <p align="center"><strong>logroller</strong></p>
2
+ <h3 align="center">Zero-dependency rotating file streams for Node.js</h3>
3
+
4
+ <div align="center">
5
+
6
+ ![npm](https://img.shields.io/npm/v/logroller)
7
+ ![node](https://img.shields.io/node/v/logroller)
8
+ ![license](https://img.shields.io/npm/l/logroller)
9
+ ![types](https://shields.io/badge/types-included-blue)
10
+
11
+ </div>
12
+
13
+ ---
14
+
15
+ `logroller` turns a plain append destination into a resilient, observable log
16
+ sink: **it is a Writable**, so `readable.pipe(log)` works, pino multistreams
17
+ work, anything that consumes streams works.
18
+
19
+ ```bash
20
+ npm install logroller
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Daily & interval rotation** — exact local midnight in any IANA zone,
26
+ or epoch-aligned intervals (`30s`, `5m`, `2h`)
27
+ - **Size caps** — `"20m"`-style limits roll numbered segments
28
+ - **Gzip archives** — sealed segments compressed atomically, level 0–9
29
+ - **Stable "current" path** — a symlink log collectors can follow
30
+ (`tail -F app-current.log`, Filebeat, promtail) while files rotate beneath
31
+ - **Retention three ways** — by age, by count, **or by total byte budget**
32
+ ("never exceed 5 GB of archives")
33
+ - **Upload pipeline** — an awaited `onSeal` hook that fires after a segment
34
+ is archived and _before_ retention can delete it: ship to S3/GCS safely
35
+ - **Graceful shutdown** — one helper flushes buffered writes on
36
+ SIGINT/SIGTERM (no more missing last lines)
37
+ - **Timezone-correct** — `Asia/Kolkata` means IST filenames, on a UTC box
38
+ - **Crash-safe** — restarts resume mid-series byte-exactly; gzips
39
+ interrupted by a kill are completed on boot
40
+ - **Observability** — `<stem>_audit.json` manifest with a uuid-tagged event
41
+ journal, plus `stats()` / `listSegments()` introspection
42
+ - **Testable time** — virtual clocks (`addHours`, `addHoursEveryMin`,
43
+ `advanceClock()`) let a day pass per minute, or instantly
44
+ - **Types first** — tuple-typed events, strict mode, dual ESM/CJS build
45
+ - **Zero dependencies**
46
+
47
+ ## Quick start
48
+
49
+ ### ESM
50
+
51
+ ```ts
52
+ import { createStream } from "logroller";
53
+
54
+ const log = createStream({
55
+ filename: "logs/app-%DATE%.log",
56
+ datePattern: "YYYY-MM-DD",
57
+ zippedArchive: true,
58
+ maxSize: "20m",
59
+ maxFiles: "90d",
60
+ tz: "UTC",
61
+ frequency: "daily",
62
+ });
63
+
64
+ log.write("hello\n");
65
+ log.on("rotated", ({ reason, archive, newFile }) =>
66
+ console.log(`${reason}: ${archive ?? "-"} → ${newFile}`),
67
+ );
68
+ process.on("exit", () => log.end());
69
+ ```
70
+
71
+ ### Production shape — collectors, budget, uploads, shutdown
72
+
73
+ ```ts
74
+ import { createStream, installShutdown } from "logroller";
75
+
76
+ const log = createStream({
77
+ filename: "logs/app-%DATE%.log",
78
+ zippedArchive: true,
79
+ maxSize: "20m",
80
+ maxTotalSize: "5g", // never hold more than 5 GB of archives
81
+ maxFiles: "90d",
82
+ symlink: true, // logs/app-current.log always points at the live file
83
+ onSeal: async ({ gz }) => uploadToS3(gz), // awaited BEFORE retention deletes
84
+ });
85
+
86
+ // SIGINT/SIGTERM → flush buffered writes, then exit
87
+ installShutdown(log);
88
+ ```
89
+
90
+ ### CommonJS
91
+
92
+ ```js
93
+ const { createStream } = require("logroller");
94
+ ```
95
+
96
+ ### pino destination
97
+
98
+ ```js
99
+ const logger = pino(
100
+ pino.multistream([
101
+ { level: "info", stream: createStream({ filename: "logs/general-%DATE%.log" }) },
102
+ { level: "error", stream: createStream({ filename: "logs/error-%DATE%.log" }) },
103
+ ]),
104
+ );
105
+ ```
106
+
107
+ ### pino transport (worker thread)
108
+
109
+ ```js
110
+ const logger = pino({
111
+ transport: {
112
+ target: "logroller/pino",
113
+ options: { filename: "logs/app-%DATE%.log", maxSize: "20m", zippedArchive: true },
114
+ },
115
+ });
116
+ ```
117
+
118
+ ### `await using`
119
+
120
+ ```ts
121
+ await using log = createStream({ filename: "logs/job-%DATE%.log" });
122
+ log.write("done\n"); // flushed and closed when the scope exits
123
+ ```
124
+
125
+ Node's default `Writable` dispose destroys the stream (dropping buffered
126
+ lines); logroller overrides it to `end()` gracefully.
127
+
128
+ ## What lands on disk
129
+
130
+ Example snapshot for `filename: "logs/app-%DATE%.log"` on **Aug 28**, with
131
+ `zippedArchive: true` and `symlink: true`:
132
+
133
+ ```
134
+ app-current.log ← symlink → app-2026-08-28.log (only with symlink: true)
135
+ app-2026-08-27.log.gz ← yesterday, sealed (hit 20 MB)
136
+ app-2026-08-27.1.log.gz ← yesterday, sealed too
137
+ app-2026-08-28.log ← ACTIVE — the only raw file on disk
138
+ app_audit.json ← library-owned manifest
139
+ ```
140
+
141
+ Three invariants that hold at every moment:
142
+
143
+ 1. **Exactly one raw file** — the active segment. Everything else is a
144
+ `.gz` archive (or a raw tail kept only because gzip failed — with a
145
+ `warn` event, never silently).
146
+ 2. **A segment's `.log` and `.log.gz` never coexist.** The raw file is
147
+ deleted only _after_ its archive is verified.
148
+ 3. **`app-current.log` always points at the active segment**, flipped
149
+ atomically on every open. It exists only when `symlink` is enabled, and
150
+ retention never touches it.
151
+
152
+ ### How a day evolves (all states, in order)
153
+
154
+ | Moment (tz) | Directory contains | `app-current.log` → |
155
+ | ------------------------- | ----------------------------------------- | ---------------------------------------------------------------- |
156
+ | morning, first write | `…27.log` | `…27.log` |
157
+ | hit 20 MB → roll | `…27.log.gz` + `…27.1.log` | `…27.1.log` |
158
+ | `.1` fills → roll | `…27.log.gz` + `…27.1.log.gz` | _(stale until next write — segments open lazily on first write)_ |
159
+ | midnight → Aug 28 + write | `…27.log.gz` + `…27.1.log.gz` + `…28.log` | `…28.log` |
160
+
161
+ A killed process resumes without gaps or duplicates:
162
+
163
+ | Disk at crash | Restart continues into |
164
+ | ------------------------------- | ------------------------------------- |
165
+ | `app-D.log` | `app-D.log` (append) |
166
+ | `app-D.log.gz` + `app-D.1.log` | `app-D.1.log` |
167
+ | `app-D.1.log.gz` (chain sealed) | `app-D.2.log` |
168
+ | died during a gzip | boot finishes the gzip, then proceeds |
169
+ | crash left yesterday's tail raw | boot seals (gzips) it automatically |
170
+
171
+ ## Log collectors & the stable current path
172
+
173
+ Rotating filenames are great for humans, hostile to consumers: `tail -f`,
174
+ Filebeat, Fluent Bit and promtail want **one path that never changes**.
175
+
176
+ ```ts
177
+ const log = createStream({
178
+ filename: "logs/app-%DATE%.log",
179
+ symlink: true, // → logs/app-current.log
180
+ // symlink: "CURRENT", // → logs/CURRENT (custom name)
181
+ });
182
+ ```
183
+
184
+ ```bash
185
+ tail -F logs/app-current.log # survives every rotation (capital -F: re-resolve the link)
186
+ ```
187
+
188
+ - The pointer is flipped **atomically** (tmp + rename) on every segment open,
189
+ so followers never observe a gap or a half-written name.
190
+ - The target is stored **relative**, so the directory stays portable.
191
+ - Best-effort by design: on filesystems that deny symlinks (Windows without
192
+ Developer Mode) the stream warns once and continues without it.
193
+ - The pointer is never touched by retention and never reported as a segment.
194
+
195
+ ## Upload pipeline — archive, ship, _then_ delete
196
+
197
+ `onSeal` runs inside the rotation queue **after** the segment is compressed
198
+ and **before** retention can remove it — the safe window for shipping:
199
+
200
+ ```ts
201
+ const log = createStream({
202
+ filename: "logs/cdr-%DATE%.log",
203
+ zippedArchive: true,
204
+ maxFiles: "7d", // local copies are disposable
205
+ onSeal: async ({ raw, gz }) => {
206
+ await uploadToS3(gz ?? raw); // awaited — rotation waits
207
+ },
208
+ sealHookTimeoutMs: 60_000, // a wedged upload warns instead of stalling
209
+ });
210
+ ```
211
+
212
+ Guarantees:
213
+
214
+ - invoked exactly once per sealed segment, with `{ raw, gz }` (`gz: null`
215
+ when compression is off or gzip failed)
216
+ - **ordering**: hook → then retention sweep, so an in-flight upload can never
217
+ race a deletion
218
+ - a throwing or timed-out hook degrades to a `warn` event — logging and
219
+ rotation continue
220
+
221
+ ## Graceful shutdown
222
+
223
+ The classic support case: SIGTERM arrives, buffered lines are lost. One call
224
+ ends it:
225
+
226
+ ```ts
227
+ import { createStream, installShutdown } from "logroller";
228
+
229
+ const log = createStream({ filename: "logs/app-%DATE%.log" });
230
+ installShutdown(log); // SIGINT + SIGTERM → end() → exit 0
231
+ ```
232
+
233
+ - `installShutdown(stream | streams, { signals?, timeoutMs?, code? })` —
234
+ installs process listeners once; streams accumulate in a registry.
235
+ - On signal: every registered stream is ended (buffered writes flushed,
236
+ audit manifest finalized), with a hard `timeoutMs` deadline (default 5 s).
237
+ - `flushAll(timeoutMs?)` is the same graceful end **without exiting** — for
238
+ tests and custom signal handling. It resolves `true` when every stream
239
+ finished, `false` if the deadline hit first; concurrent calls share one flush.
240
+
241
+ ## Introspection
242
+
243
+ ```ts
244
+ const st = log.stats();
245
+ // { dir, currentStamp, activeFile, activeIndex, segments, archived,
246
+ // totalBytes, clockOffsetMs, symlink }
247
+
248
+ for (const seg of log.listSegments()) {
249
+ // { file, stamp, index, gzipped, bytes, state, openedAt?, archivedAt? }
250
+ console.log(seg.stamp, seg.index, seg.gzipped ? "gz" : "raw", seg.bytes);
251
+ }
252
+ ```
253
+
254
+ `listSegments()` reads the **directory** (disk is truth), so it also reports
255
+ files the manifest doesn't know (state `"untracked"`). Perfect for
256
+ `/healthz` endpoints, dashboards, and "where did my logs go" support.
257
+
258
+ ## Retention — three policies, composable
259
+
260
+ ```ts
261
+ maxFiles: "90d" // age: delete archives older than 90 days
262
+ maxFiles: 500 // count: keep the newest 500 archives
263
+ maxTotalSize: "5g" // budget: delete OLDEST archives until under 5 GB
264
+ maxFiles: "30d",
265
+ maxTotalSize: "2g", // combined: either policy may delete
266
+ ```
267
+
268
+ Unset, `0`, `"0"` or `"0d"` means files live forever until you delete them. The active segment is
269
+ never a retention candidate, and the audit/symlink files are always reserved.
270
+
271
+ ## API
272
+
273
+ ### `createStream(options)` → `RotateFileStream`
274
+
275
+ ### Options
276
+
277
+ | Option | Values | Default |
278
+ | --------------------- | ------------------------------------- | ------------------ |
279
+ | `filename` | **required**, must contain `%DATE%` | — |
280
+ | `datePattern` | `YYYY YY MM DD HH mm ss` | `YYYY-MM-DD` |
281
+ | `zippedArchive` | boolean | `false` |
282
+ | `compressionLevel` | gzip level `0`–`9` (0 = stored) | `6` |
283
+ | `maxSize` | `20m`, `512k`, `2g`, bytes, `0`=off | off |
284
+ | `maxFiles` | `"90d"` age, `500` count, unset=never | — |
285
+ | `maxTotalSize` | `"5g"`, `"500m"`, bytes, `0`=off | off |
286
+ | `symlink` | `true` (auto name) or custom string | off |
287
+ | `onSeal` | `async ({ raw, gz }) => void` | — |
288
+ | `sealHookTimeoutMs` | hard deadline for one `onSeal` call | `30000` |
289
+ | `frequency` | `daily`, `30s`, `5m`, `2h`, ms | `daily` |
290
+ | `tz` | any IANA zone | `UTC` |
291
+ | `audit` / `auditFile` | manifest control | `true` / auto-name |
292
+ | `unrefTimers` | detach from event loop | `false` |
293
+ | `addHours` | ⚠️ static clock shift, test only | off |
294
+ | `addHoursEveryMin` | ⚠️ virtual time per tick, test only | off |
295
+ | `clockStepIntervalMs` | tick interval | `60000` |
296
+
297
+ ### Methods
298
+
299
+ | Method | Returns | Purpose |
300
+ | ----------------------- | ---------------- | ------------------------------- |
301
+ | `write / end / destroy` | stream semantics | normal `Writable` |
302
+ | `rotateNow()` | `Promise<void>` | force a rotation |
303
+ | `advanceClock()` | `Promise<void>` | apply one virtual tick now |
304
+ | `stats()` | `StreamStats` | health snapshot |
305
+ | `listSegments()` | `SegmentInfo[]` | every family file, oldest first |
306
+
307
+ ### Module functions
308
+
309
+ | Function | Purpose |
310
+ | --------------------------------- | ------------------------------------------------------------ |
311
+ | `installShutdown(streams, opts?)` | register streams for SIGINT/SIGTERM flush; returns `flush()` |
312
+ | `flushAll(timeoutMs?)` | end all registered streams, no exit; `Promise<boolean>` |
313
+
314
+ ### Events (typed)
315
+
316
+ | Event | Payload | Meaning |
317
+ | --------- | ------------------------------------- | ------------------------------------------------------------ |
318
+ | `open` | `file` | segment opened for appending |
319
+ | `rotated` | `{reason, oldFile, archive, newFile}` | segment closed |
320
+ | `archive` | `gzFile` | gzip finished |
321
+ | `deleted` | `file` | retention removed it |
322
+ | `period` | `stamp` | boundary crossed while idle |
323
+ | `clock` | `{offsetMs, …}` | virtual clock engaged |
324
+ | `warn` | `Error` | non-fatal (hook failure, gzip retry, symlink unavailable, …) |
325
+ | `error` | `Error` | fatal write problems |
326
+
327
+ Errors must be handled — attach a listener on every instance.
328
+
329
+ ## Testing rotation without waiting a week
330
+
331
+ ```ts
332
+ const s = createStream({
333
+ filename: "logs/demo-%DATE%.log",
334
+ zippedArchive: true,
335
+ addHours: "-48h", // start two days back
336
+ addHoursEveryMin: "24h", // one virtual day per real minute
337
+ clockStepIntervalMs: 1000, // or faster
338
+ });
339
+ await s.advanceClock(); // jump instantly when useful
340
+ ```
341
+
342
+ Negative `addHoursEveryMin` deliberately triggers the backward-clock guard
343
+ (warn, no rotation) — it exists so you can test that safety net too.
344
+
345
+ ## FAQ
346
+
347
+ **Why is `%DATE%` mandatory?**
348
+ It is the anchor that decouples _rotation scheduling_ from _filenames_.
349
+ Coarser patterns than your frequency collapse distinct segments onto one
350
+ name — match tokens to cadence (`HH` for hourly, etc.). The library warns at
351
+ construction when it detects a mismatch.
352
+
353
+ **How do I ship logs to S3 without losing any?**
354
+ `zippedArchive: true` + `onSeal` (see the upload pipeline section). The hook
355
+ is awaited and runs before retention, so an in-flight upload can never race
356
+ a deletion.
357
+
358
+ **How do I keep `tail -f` working across rotations?**
359
+ `symlink: true` — follow `app-current.log`. Use `tail -F` (capital) so the
360
+ follower re-resolves the link after it flips.
361
+
362
+ **Can two processes share one directory?**
363
+ Different prefixes/families: yes. The _same_ family: no — the audit manifest
364
+ detects a live second writer (`writer.heartbeat`) and warns loudly.
365
+
366
+ **Is the audit JSON required?**
367
+ No — `audit: false` reduces behaviour to pure disk-scan recovery. Recommended
368
+ to leave on: it repairs interrupted gzips, demotes zombie records, and
369
+ powers `listSegments()` states.
370
+
371
+ **What if the system clock jumps backward?**
372
+ The stream keeps the current segment, warns once, and resumes normal
373
+ rotation when the clock catches up — the series is never forked into a past
374
+ family. (Requires zero-padded `datePattern` tokens, which all presets are.)
375
+
376
+ **Windows / macOS / Linux?**
377
+ Pure Node APIs; path handling via `node:path`. All three supported
378
+ (Node `>=16.14`). Symlinks are best-effort on Windows — see above.
379
+
380
+ ## Compare
381
+
382
+ | | logroller | file-stream-rotator | rotating-file-stream | winston-daily-rotate-file |
383
+ | --------------------------------- | ----------------- | ------------------- | -------------------- | ------------------------- |
384
+ | Dependencies | **0** | 4+ | 2+ | winston-bound |
385
+ | Crash-safe resume | ✅ audit-assisted | partial | partial | ✖ |
386
+ | Interrupted-gzip repair | ✅ | ✖ | ✖ | ✖ |
387
+ | Event journal | ✅ | partial | ✖ | ✖ |
388
+ | TZ-correct daily rollover | ✅ | partial | ✅ | ✅ |
389
+ | Stable current path | ✅ atomic | ✅ | ✅ | ✅ |
390
+ | Byte-budget retention | ✅ | ✅ (bytes only) | ✖ | ✖ |
391
+ | Awaited upload-before-delete hook | ✅ | ✖ | ✖ | ✖ |
392
+ | Graceful shutdown helper | ✅ | ✖ | ✖ | via winston |
393
+ | Introspection API | ✅ | ✖ | ✖ | partial |
394
+ | Virtual test clock | ✅ | ✖ | ✖ | ✖ |
395
+ | Winston required | no | no | no | yes |
396
+
397
+ ## Contributing
398
+
399
+ PRs welcome. `npm run verify` gates every change (typecheck + tests), and CI
400
+ runs it on Linux, macOS and Windows × Node 22/24, plus a runtime smoke test
401
+ on Node 16/18/20. (Building and the full test suite need Node 22.6+.)
402
+ Please file issues before large refactors.
403
+
404
+ ## License
405
+
406
+ [MIT](./LICENSE)