logroller 2.1.0 → 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 +406 -382
- package/dist/index.cjs +2 -1336
- package/dist/{shutdown.d.ts → index.d.cts} +13 -6
- package/dist/index.d.ts +36 -6
- package/dist/index.esm.js +2 -1331
- package/dist/pino.cjs +2 -0
- package/dist/pino.d.cts +6 -0
- package/dist/pino.d.ts +6 -0
- package/dist/pino.esm.js +2 -0
- package/dist/rotate-file-stream.cjs +2 -0
- package/dist/rotate-file-stream.d.cts +350 -0
- package/dist/rotate-file-stream.d.ts +198 -7
- package/dist/rotate-file-stream.esm.js +2 -0
- package/package.json +27 -10
- package/CHANGELOG.md +0 -151
- package/dist/fsio.d.ts +0 -11
- package/dist/parsers.d.ts +0 -18
- package/dist/time.d.ts +0 -13
- package/dist/types.d.ts +0 -159
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
|
-

|
|
7
|
-

|
|
8
|
-

|
|
9
|
-

|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
###
|
|
276
|
-
|
|
277
|
-
|
|
|
278
|
-
|
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
| `
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
|
288
|
-
|
|
|
289
|
-
| `
|
|
290
|
-
| `
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
|
295
|
-
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
|
300
|
-
|
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
##
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
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)
|