@diveinto/obs 1.0.3 → 1.0.4
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 +8 -1
- package/dist/sinks.d.ts +21 -1
- package/dist/sinks.js +87 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,14 @@ 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.
|
|
31
38
|
- **Trace context** on `AsyncLocalStorage`, continuing an inbound W3C
|
|
32
39
|
`traceparent` and propagating it outbound, so one `trace_id` spans every
|
|
33
40
|
hop.
|
package/dist/sinks.d.ts
CHANGED
|
@@ -36,11 +36,31 @@ export declare class RotatingFileWriter {
|
|
|
36
36
|
write(rec: ObsRecord): void;
|
|
37
37
|
private maybeRotate;
|
|
38
38
|
private rotateTime;
|
|
39
|
+
/**
|
|
40
|
+
* Where `path` goes when the period rolls: `path.<key>`, unless that
|
|
41
|
+
* already exists.
|
|
42
|
+
*
|
|
43
|
+
* `renameSync` overwrites without a word, so rotating twice onto the same
|
|
44
|
+
* name destroys the first file. That is not theoretical: it took a whole
|
|
45
|
+
* day of the commander's records on r2d2 on 2026-09-22, when two writers
|
|
46
|
+
* in one process both rolled to `.2026-09-21`. The writers are shared now
|
|
47
|
+
* and should not collide, but a second process (a restart overlapping its
|
|
48
|
+
* predecessor) can still reach this line, and losing a day of logs to a
|
|
49
|
+
* rename is a poor trade for a tidy filename.
|
|
50
|
+
*/
|
|
51
|
+
private rotatedName;
|
|
39
52
|
private rotateSize;
|
|
40
53
|
/** Delete rotated files beyond `retention` (time-based keeps the newest by
|
|
41
54
|
* their date-sortable suffix). */
|
|
42
55
|
private prune;
|
|
43
56
|
private fail;
|
|
44
57
|
}
|
|
45
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Build a file `Sink` wrapping a `RotatingFileWriter`.
|
|
60
|
+
*
|
|
61
|
+
* One writer per path per process, shared across module contexts. Two
|
|
62
|
+
* writers on one file is not a harmless duplicate: each owns an fd and its
|
|
63
|
+
* own rotation state, so they rename the file out from under each other and
|
|
64
|
+
* the one an operator greps stops advancing. See the slots at the top.
|
|
65
|
+
*/
|
|
46
66
|
export declare function makeFileSink(path: string, format: string, render: (rec: ObsRecord) => string, rotation: string, retention: 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 {
|
|
@@ -159,7 +194,7 @@ export class RotatingFileWriter {
|
|
|
159
194
|
rotateTime(prevKey) {
|
|
160
195
|
this.stream?.end();
|
|
161
196
|
try {
|
|
162
|
-
renameSync(this.path,
|
|
197
|
+
renameSync(this.path, this.rotatedName(prevKey));
|
|
163
198
|
}
|
|
164
199
|
catch {
|
|
165
200
|
/* if the rename fails we just keep appending to the same file */
|
|
@@ -167,6 +202,29 @@ export class RotatingFileWriter {
|
|
|
167
202
|
this.open();
|
|
168
203
|
this.prune();
|
|
169
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Where `path` goes when the period rolls: `path.<key>`, unless that
|
|
207
|
+
* already exists.
|
|
208
|
+
*
|
|
209
|
+
* `renameSync` overwrites without a word, so rotating twice onto the same
|
|
210
|
+
* name destroys the first file. That is not theoretical: it took a whole
|
|
211
|
+
* day of the commander's records on r2d2 on 2026-09-22, when two writers
|
|
212
|
+
* in one process both rolled to `.2026-09-21`. The writers are shared now
|
|
213
|
+
* and should not collide, but a second process (a restart overlapping its
|
|
214
|
+
* predecessor) can still reach this line, and losing a day of logs to a
|
|
215
|
+
* rename is a poor trade for a tidy filename.
|
|
216
|
+
*/
|
|
217
|
+
rotatedName(key) {
|
|
218
|
+
const base = `${this.path}.${key}`;
|
|
219
|
+
if (!existsSync(base))
|
|
220
|
+
return base;
|
|
221
|
+
for (let i = 1; i < 100; i++) {
|
|
222
|
+
const candidate = `${base}.${i}`;
|
|
223
|
+
if (!existsSync(candidate))
|
|
224
|
+
return candidate;
|
|
225
|
+
}
|
|
226
|
+
return `${base}.${Date.now()}`;
|
|
227
|
+
}
|
|
170
228
|
rotateSize() {
|
|
171
229
|
this.stream?.end();
|
|
172
230
|
// Shift path.{retention-1} -> path.{retention} ... path.1 -> path.2, then
|
|
@@ -225,9 +283,21 @@ export class RotatingFileWriter {
|
|
|
225
283
|
}
|
|
226
284
|
}
|
|
227
285
|
}
|
|
228
|
-
/**
|
|
286
|
+
/**
|
|
287
|
+
* Build a file `Sink` wrapping a `RotatingFileWriter`.
|
|
288
|
+
*
|
|
289
|
+
* One writer per path per process, shared across module contexts. Two
|
|
290
|
+
* writers on one file is not a harmless duplicate: each owns an fd and its
|
|
291
|
+
* own rotation state, so they rename the file out from under each other and
|
|
292
|
+
* the one an operator greps stops advancing. See the slots at the top.
|
|
293
|
+
*/
|
|
229
294
|
export function makeFileSink(path, format, render, rotation, retention) {
|
|
230
|
-
const
|
|
295
|
+
const open = writers();
|
|
296
|
+
let writer = open.get(path);
|
|
297
|
+
if (!writer) {
|
|
298
|
+
writer = new RotatingFileWriter(path, rotation, retention, render);
|
|
299
|
+
open.set(path, writer);
|
|
300
|
+
}
|
|
231
301
|
return {
|
|
232
302
|
name: basename(path),
|
|
233
303
|
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.4",
|
|
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,
|