@diveinto/obs 1.0.3 → 1.0.5
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 +18 -3
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +15 -2
- package/dist/sinks.d.ts +45 -3
- package/dist/sinks.js +113 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,20 @@ of the four, and each of those behaviours is pinned by a test.
|
|
|
27
27
|
follow a trace across services written in either language.
|
|
28
28
|
- **Three sinks**: stdout (always), and two rotating files that are
|
|
29
29
|
format-locked, so `<stem>.log` is always text and `<stem>.jsonl` is always
|
|
30
|
-
JSON regardless of `LOG_FORMAT`. Daily rotation, 14 kept, both
|
|
30
|
+
JSON regardless of `LOG_FORMAT`. Daily rotation, 14 kept, both
|
|
31
|
+
configurable. **One writer per path per process**, shared across module
|
|
32
|
+
contexts on `globalThis` along with the sink registry and the level floor
|
|
33
|
+
(v1.0.4): a framework that runs your instrumentation hook in a different
|
|
34
|
+
module context from your route handlers will otherwise build a second
|
|
35
|
+
writer on the same file, and the two then rename it out from under each
|
|
36
|
+
other at the rotation boundary. Rotation also refuses to overwrite an
|
|
37
|
+
existing rotated file, so a day of records cannot be lost to a rename.
|
|
38
|
+
A single file also has a **ceiling** (256 MB by default,
|
|
39
|
+
`<PREFIX>_LOG_JSON_MAX_MB` / `_TEXT_MAX_MB`, 0 to remove): daily rotation
|
|
40
|
+
bounds how many files there are, not how large one can get, and a service
|
|
41
|
+
that drops to DEBUG or loops on an error can write more in an afternoon
|
|
42
|
+
than the fortnight around it. Worst case per service is now a number an
|
|
43
|
+
operator can reason about rather than the free space on the disk.
|
|
31
44
|
- **Trace context** on `AsyncLocalStorage`, continuing an inbound W3C
|
|
32
45
|
`traceparent` and propagating it outbound, so one `trace_id` spans every
|
|
33
46
|
hop.
|
|
@@ -93,8 +106,10 @@ Generic, never prefixed: `LOG_LEVEL` (`trace`/`debug`/`info`/`warn`/`error`/
|
|
|
93
106
|
`OTEL_*` set.
|
|
94
107
|
|
|
95
108
|
Per service, prefixed: `<PREFIX>_VAR_DIR`, `<PREFIX>_LOG_TEXT_FILE`,
|
|
96
|
-
`_TEXT_ROTATION`, `_TEXT_RETENTION`, and the same
|
|
97
|
-
`*_FILE` value is a path, or `1` for the default path, or
|
|
109
|
+
`_TEXT_ROTATION`, `_TEXT_RETENTION`, `_TEXT_MAX_MB`, and the same four for
|
|
110
|
+
`_JSON_`. A `*_FILE` value is a path, or `1` for the default path, or
|
|
111
|
+
`0`/`off`; `*_MAX_MB` is the per-file ceiling, 256 by default and 0 for
|
|
112
|
+
none.
|
|
98
113
|
|
|
99
114
|
## Cardinality
|
|
100
115
|
|
package/dist/setup.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type FileTarget = {
|
|
|
5
5
|
path: string;
|
|
6
6
|
rotation: string;
|
|
7
7
|
retention: number;
|
|
8
|
+
/** Hard ceiling on one file, bytes; 0 for none. See maxMB below. */
|
|
9
|
+
maxBytes: number;
|
|
8
10
|
};
|
|
9
11
|
/**
|
|
10
12
|
* Resolve where the two files go, as a pure function of the environment so it
|
package/dist/setup.js
CHANGED
|
@@ -42,6 +42,17 @@ function stateDir(env) {
|
|
|
42
42
|
const cfg = obsConfig();
|
|
43
43
|
return env[`${cfg.envPrefix}_VAR_DIR`] || cfg.defaultVarDir;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The default ceiling on a single log file, in megabytes.
|
|
47
|
+
*
|
|
48
|
+
* Daily rotation bounds the NUMBER of files, not the size of one. A service
|
|
49
|
+
* that drops to DEBUG or loops on an error can write more in an afternoon
|
|
50
|
+
* than the fortnight around it, and the first thing to notice would be the
|
|
51
|
+
* disk. 256 MB a file, times fourteen days, times two files is about 7 GB
|
|
52
|
+
* worst case per service, which is a bound an operator can reason about.
|
|
53
|
+
* `<PREFIX>_LOG_JSON_MAX_MB` / `_TEXT_MAX_MB` change it; 0 removes it.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_MAX_MB = 256;
|
|
45
56
|
/**
|
|
46
57
|
* Resolve where the two files go, as a pure function of the environment so it
|
|
47
58
|
* can be tested without touching the filesystem. Defaults land under
|
|
@@ -60,6 +71,7 @@ export function resolveFileTargets(env = process.env) {
|
|
|
60
71
|
path: text === true ? join(dir, `${cfg.fileStem}.log`) : text,
|
|
61
72
|
rotation: (env[`${p}_LOG_TEXT_ROTATION`] || "daily").trim(),
|
|
62
73
|
retention: toInt(env[`${p}_LOG_TEXT_RETENTION`], 14),
|
|
74
|
+
maxBytes: toInt(env[`${p}_LOG_TEXT_MAX_MB`], DEFAULT_MAX_MB) * 1024 * 1024,
|
|
63
75
|
}
|
|
64
76
|
: null,
|
|
65
77
|
json: jsonFile
|
|
@@ -67,6 +79,7 @@ export function resolveFileTargets(env = process.env) {
|
|
|
67
79
|
path: jsonFile === true ? join(dir, `${cfg.fileStem}.jsonl`) : jsonFile,
|
|
68
80
|
rotation: (env[`${p}_LOG_JSON_ROTATION`] || "daily").trim(),
|
|
69
81
|
retention: toInt(env[`${p}_LOG_JSON_RETENTION`], 14),
|
|
82
|
+
maxBytes: toInt(env[`${p}_LOG_JSON_MAX_MB`], DEFAULT_MAX_MB) * 1024 * 1024,
|
|
70
83
|
}
|
|
71
84
|
: null,
|
|
72
85
|
};
|
|
@@ -130,10 +143,10 @@ export function setupLogging() {
|
|
|
130
143
|
if (!serverless && isConfigured()) {
|
|
131
144
|
const targets = resolveFileTargets();
|
|
132
145
|
if (targets.text) {
|
|
133
|
-
sinks.push(makeFileSink(targets.text.path, "pretty", renderPretty, targets.text.rotation, targets.text.retention));
|
|
146
|
+
sinks.push(makeFileSink(targets.text.path, "pretty", renderPretty, targets.text.rotation, targets.text.retention, targets.text.maxBytes));
|
|
134
147
|
}
|
|
135
148
|
if (targets.json) {
|
|
136
|
-
sinks.push(makeFileSink(targets.json.path, "json", renderJson, targets.json.rotation, targets.json.retention));
|
|
149
|
+
sinks.push(makeFileSink(targets.json.path, "json", renderJson, targets.json.rotation, targets.json.retention, targets.json.maxBytes));
|
|
137
150
|
}
|
|
138
151
|
}
|
|
139
152
|
// When OTLP export is on, also ship logs to the backend's Logs view
|
package/dist/sinks.d.ts
CHANGED
|
@@ -24,11 +24,33 @@ export declare class RotatingFileWriter {
|
|
|
24
24
|
private readonly rotation;
|
|
25
25
|
private readonly retention;
|
|
26
26
|
private readonly render;
|
|
27
|
+
/**
|
|
28
|
+
* A hard ceiling on ONE file, in bytes, on top of time rotation.
|
|
29
|
+
*
|
|
30
|
+
* Daily rotation bounds how many files there are, not how big one can
|
|
31
|
+
* get: a service that starts logging at DEBUG, or loops on an error,
|
|
32
|
+
* can write far more in a day than the fourteen quiet days around it,
|
|
33
|
+
* and nothing stops it before the disk does. With this, the day rolls
|
|
34
|
+
* early instead. Zero disables it, which is what a size-based
|
|
35
|
+
* `rotation` already means.
|
|
36
|
+
*/
|
|
37
|
+
private readonly maxBytes;
|
|
27
38
|
private stream;
|
|
28
39
|
private bytes;
|
|
29
40
|
private periodKey;
|
|
30
41
|
private disabled;
|
|
31
|
-
constructor(path: string, rotation: string, retention: number, render: (rec: ObsRecord) => string
|
|
42
|
+
constructor(path: string, rotation: string, retention: number, render: (rec: ObsRecord) => string,
|
|
43
|
+
/**
|
|
44
|
+
* A hard ceiling on ONE file, in bytes, on top of time rotation.
|
|
45
|
+
*
|
|
46
|
+
* Daily rotation bounds how many files there are, not how big one can
|
|
47
|
+
* get: a service that starts logging at DEBUG, or loops on an error,
|
|
48
|
+
* can write far more in a day than the fourteen quiet days around it,
|
|
49
|
+
* and nothing stops it before the disk does. With this, the day rolls
|
|
50
|
+
* early instead. Zero disables it, which is what a size-based
|
|
51
|
+
* `rotation` already means.
|
|
52
|
+
*/
|
|
53
|
+
maxBytes?: number);
|
|
32
54
|
private open;
|
|
33
55
|
/** The bucket key for time-based rotation (empty for size-based). */
|
|
34
56
|
private currentPeriod;
|
|
@@ -36,11 +58,31 @@ export declare class RotatingFileWriter {
|
|
|
36
58
|
write(rec: ObsRecord): void;
|
|
37
59
|
private maybeRotate;
|
|
38
60
|
private rotateTime;
|
|
61
|
+
/**
|
|
62
|
+
* Where `path` goes when the period rolls: `path.<key>`, unless that
|
|
63
|
+
* already exists.
|
|
64
|
+
*
|
|
65
|
+
* `renameSync` overwrites without a word, so rotating twice onto the same
|
|
66
|
+
* name destroys the first file. That is not theoretical: it took a whole
|
|
67
|
+
* day of the commander's records on r2d2 on 2026-09-22, when two writers
|
|
68
|
+
* in one process both rolled to `.2026-09-21`. The writers are shared now
|
|
69
|
+
* and should not collide, but a second process (a restart overlapping its
|
|
70
|
+
* predecessor) can still reach this line, and losing a day of logs to a
|
|
71
|
+
* rename is a poor trade for a tidy filename.
|
|
72
|
+
*/
|
|
73
|
+
private rotatedName;
|
|
39
74
|
private rotateSize;
|
|
40
75
|
/** Delete rotated files beyond `retention` (time-based keeps the newest by
|
|
41
76
|
* their date-sortable suffix). */
|
|
42
77
|
private prune;
|
|
43
78
|
private fail;
|
|
44
79
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Build a file `Sink` wrapping a `RotatingFileWriter`.
|
|
82
|
+
*
|
|
83
|
+
* One writer per path per process, shared across module contexts. Two
|
|
84
|
+
* writers on one file is not a harmless duplicate: each owns an fd and its
|
|
85
|
+
* own rotation state, so they rename the file out from under each other and
|
|
86
|
+
* the one an operator greps stops advancing. See the slots at the top.
|
|
87
|
+
*/
|
|
88
|
+
export declare function makeFileSink(path: string, format: string, render: (rec: ObsRecord) => string, rotation: string, retention: number, maxBytes?: number): Sink;
|
package/dist/sinks.js
CHANGED
|
@@ -15,17 +15,49 @@
|
|
|
15
15
|
* This module is Node-only (it imports `node:fs`); it is reached solely through
|
|
16
16
|
* `setup.ts`, which only runs in the Node runtime.
|
|
17
17
|
*/
|
|
18
|
-
import { createWriteStream, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, } from "node:fs";
|
|
18
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, } from "node:fs";
|
|
19
19
|
import { basename, dirname, join } from "node:path";
|
|
20
20
|
import { LEVELS } from "./types.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The active sinks, the level floor, and every open file writer live on
|
|
23
|
+
* `globalThis`, NOT in module-level variables.
|
|
24
|
+
*
|
|
25
|
+
* Same scar as the configuration one layer up, and found the same way. The
|
|
26
|
+
* config moved to globalThis when a second module context read it as unset;
|
|
27
|
+
* the SINKS did not move with it, and the lazy initialiser below then built
|
|
28
|
+
* a SECOND set of sinks for that context - a second RotatingFileWriter on
|
|
29
|
+
* the same path, with its own fd and its own rotation bookkeeping.
|
|
30
|
+
*
|
|
31
|
+
* What that does on a real host (r2d2, 2026-09-22): at the day boundary one
|
|
32
|
+
* writer renames `commander.jsonl` to `commander.jsonl.2026-09-21` and opens
|
|
33
|
+
* a fresh file; the other writer's fd still points at the renamed inode, so
|
|
34
|
+
* it goes on appending there. The file an operator greps stops advancing
|
|
35
|
+
* while the service is plainly still logging, and the rotated file fills
|
|
36
|
+
* with records from the WRONG day. Worse, `renameSync` overwrites, so the
|
|
37
|
+
* second rotation destroyed the real previous day.
|
|
38
|
+
*
|
|
39
|
+
* One process, one writer per path. That is what these slots buy.
|
|
40
|
+
*/
|
|
41
|
+
const SINKS_SLOT = Symbol.for("diveinto.obs.sinks");
|
|
42
|
+
const WRITERS_SLOT = Symbol.for("diveinto.obs.writers");
|
|
43
|
+
function holder() {
|
|
44
|
+
return globalThis;
|
|
45
|
+
}
|
|
46
|
+
function state() {
|
|
47
|
+
const h = holder();
|
|
48
|
+
h[SINKS_SLOT] ??= { sinks: [], threshold: LEVELS.INFO };
|
|
49
|
+
return h[SINKS_SLOT];
|
|
50
|
+
}
|
|
51
|
+
/** Every file writer this process has opened, by absolute path. */
|
|
52
|
+
function writers() {
|
|
53
|
+
const h = holder();
|
|
54
|
+
h[WRITERS_SLOT] ??= new Map();
|
|
55
|
+
return h[WRITERS_SLOT];
|
|
56
|
+
}
|
|
26
57
|
export function configureSinks(sinks, threshold) {
|
|
27
|
-
|
|
28
|
-
|
|
58
|
+
const st = state();
|
|
59
|
+
st.sinks = sinks;
|
|
60
|
+
st.threshold = threshold;
|
|
29
61
|
}
|
|
30
62
|
// Lazy self-initialization. Next.js can bundle instrumentation.ts (which calls
|
|
31
63
|
// setupLogging) in a SEPARATE module graph from the route handlers, so a route
|
|
@@ -39,24 +71,27 @@ export function setSinkInitializer(fn) {
|
|
|
39
71
|
initializer = fn;
|
|
40
72
|
}
|
|
41
73
|
export function setThreshold(threshold) {
|
|
42
|
-
|
|
74
|
+
state().threshold = threshold;
|
|
43
75
|
}
|
|
44
76
|
export function activeSinks() {
|
|
45
|
-
return
|
|
77
|
+
return state().sinks;
|
|
46
78
|
}
|
|
47
79
|
/** Dispatch one record to every eligible sink. Sinks swallow their own I/O
|
|
48
80
|
* errors, but we belt-and-brace with a try/catch so logging never throws into
|
|
49
81
|
* application code. */
|
|
50
82
|
export function emitToSinks(rec) {
|
|
51
|
-
// Configure
|
|
52
|
-
|
|
83
|
+
// Configure on first use if some context has not done it yet (see
|
|
84
|
+
// setSinkInitializer). The sinks it builds are shared, so this runs at
|
|
85
|
+
// most once per process however many module contexts reach it.
|
|
86
|
+
const st = state();
|
|
87
|
+
if (st.sinks.length === 0 && initializer && !initAttempted) {
|
|
53
88
|
initAttempted = true;
|
|
54
89
|
initializer();
|
|
55
90
|
}
|
|
56
91
|
const lvl = LEVELS[rec.level];
|
|
57
|
-
if (lvl <
|
|
92
|
+
if (lvl < st.threshold)
|
|
58
93
|
return;
|
|
59
|
-
for (const sink of
|
|
94
|
+
for (const sink of st.sinks) {
|
|
60
95
|
if (lvl < sink.minLevel)
|
|
61
96
|
continue;
|
|
62
97
|
try {
|
|
@@ -91,15 +126,28 @@ export class RotatingFileWriter {
|
|
|
91
126
|
rotation;
|
|
92
127
|
retention;
|
|
93
128
|
render;
|
|
129
|
+
maxBytes;
|
|
94
130
|
stream = null;
|
|
95
131
|
bytes = 0;
|
|
96
132
|
periodKey = "";
|
|
97
133
|
disabled = false;
|
|
98
|
-
constructor(path, rotation, retention, render
|
|
134
|
+
constructor(path, rotation, retention, render,
|
|
135
|
+
/**
|
|
136
|
+
* A hard ceiling on ONE file, in bytes, on top of time rotation.
|
|
137
|
+
*
|
|
138
|
+
* Daily rotation bounds how many files there are, not how big one can
|
|
139
|
+
* get: a service that starts logging at DEBUG, or loops on an error,
|
|
140
|
+
* can write far more in a day than the fourteen quiet days around it,
|
|
141
|
+
* and nothing stops it before the disk does. With this, the day rolls
|
|
142
|
+
* early instead. Zero disables it, which is what a size-based
|
|
143
|
+
* `rotation` already means.
|
|
144
|
+
*/
|
|
145
|
+
maxBytes = 0) {
|
|
99
146
|
this.path = path;
|
|
100
147
|
this.rotation = rotation;
|
|
101
148
|
this.retention = retention;
|
|
102
149
|
this.render = render;
|
|
150
|
+
this.maxBytes = maxBytes;
|
|
103
151
|
try {
|
|
104
152
|
mkdirSync(dirname(path), { recursive: true });
|
|
105
153
|
this.open();
|
|
@@ -149,17 +197,24 @@ export class RotatingFileWriter {
|
|
|
149
197
|
const maxBytes = (parseInt(this.rotation, 10) || 100) * 1024 * 1024;
|
|
150
198
|
if (this.bytes > 0 && this.bytes + incoming > maxBytes)
|
|
151
199
|
this.rotateSize();
|
|
200
|
+
return;
|
|
152
201
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
202
|
+
const now = this.currentPeriod();
|
|
203
|
+
if (this.periodKey && now !== this.periodKey) {
|
|
204
|
+
this.rotateTime(this.periodKey);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// The day has not rolled, but this file has outgrown its ceiling. Roll
|
|
208
|
+
// it under today's key; rotatedName() keeps the earlier part rather
|
|
209
|
+
// than renaming over it, so the day survives as .<key>, .<key>.1, ...
|
|
210
|
+
if (this.maxBytes > 0 && this.bytes > 0 && this.bytes + incoming > this.maxBytes) {
|
|
211
|
+
this.rotateTime(now);
|
|
157
212
|
}
|
|
158
213
|
}
|
|
159
214
|
rotateTime(prevKey) {
|
|
160
215
|
this.stream?.end();
|
|
161
216
|
try {
|
|
162
|
-
renameSync(this.path,
|
|
217
|
+
renameSync(this.path, this.rotatedName(prevKey));
|
|
163
218
|
}
|
|
164
219
|
catch {
|
|
165
220
|
/* if the rename fails we just keep appending to the same file */
|
|
@@ -167,6 +222,29 @@ export class RotatingFileWriter {
|
|
|
167
222
|
this.open();
|
|
168
223
|
this.prune();
|
|
169
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Where `path` goes when the period rolls: `path.<key>`, unless that
|
|
227
|
+
* already exists.
|
|
228
|
+
*
|
|
229
|
+
* `renameSync` overwrites without a word, so rotating twice onto the same
|
|
230
|
+
* name destroys the first file. That is not theoretical: it took a whole
|
|
231
|
+
* day of the commander's records on r2d2 on 2026-09-22, when two writers
|
|
232
|
+
* in one process both rolled to `.2026-09-21`. The writers are shared now
|
|
233
|
+
* and should not collide, but a second process (a restart overlapping its
|
|
234
|
+
* predecessor) can still reach this line, and losing a day of logs to a
|
|
235
|
+
* rename is a poor trade for a tidy filename.
|
|
236
|
+
*/
|
|
237
|
+
rotatedName(key) {
|
|
238
|
+
const base = `${this.path}.${key}`;
|
|
239
|
+
if (!existsSync(base))
|
|
240
|
+
return base;
|
|
241
|
+
for (let i = 1; i < 100; i++) {
|
|
242
|
+
const candidate = `${base}.${i}`;
|
|
243
|
+
if (!existsSync(candidate))
|
|
244
|
+
return candidate;
|
|
245
|
+
}
|
|
246
|
+
return `${base}.${Date.now()}`;
|
|
247
|
+
}
|
|
170
248
|
rotateSize() {
|
|
171
249
|
this.stream?.end();
|
|
172
250
|
// Shift path.{retention-1} -> path.{retention} ... path.1 -> path.2, then
|
|
@@ -225,9 +303,21 @@ export class RotatingFileWriter {
|
|
|
225
303
|
}
|
|
226
304
|
}
|
|
227
305
|
}
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Build a file `Sink` wrapping a `RotatingFileWriter`.
|
|
308
|
+
*
|
|
309
|
+
* One writer per path per process, shared across module contexts. Two
|
|
310
|
+
* writers on one file is not a harmless duplicate: each owns an fd and its
|
|
311
|
+
* own rotation state, so they rename the file out from under each other and
|
|
312
|
+
* the one an operator greps stops advancing. See the slots at the top.
|
|
313
|
+
*/
|
|
314
|
+
export function makeFileSink(path, format, render, rotation, retention, maxBytes = 0) {
|
|
315
|
+
const open = writers();
|
|
316
|
+
let writer = open.get(path);
|
|
317
|
+
if (!writer) {
|
|
318
|
+
writer = new RotatingFileWriter(path, rotation, retention, render, maxBytes);
|
|
319
|
+
open.set(path, writer);
|
|
320
|
+
}
|
|
231
321
|
return {
|
|
232
322
|
name: basename(path),
|
|
233
323
|
minLevel: LEVELS.DEBUG,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diveinto/obs",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Structured logging, tracing and metrics for Next.js and Node services: one record shape, rotating files, W3C trace context, optional OpenTelemetry export",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|