@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 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 configurable.
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
- /** Build a file `Sink` wrapping a `RotatingFileWriter`. */
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
- // The active sinks and the global level floor. Mutable so a future live-level
22
- // endpoint can raise/lower the floor without a restart (mirrors the agent's
23
- // PUT /v1/system/logging).
24
- let SINKS = [];
25
- let THRESHOLD = LEVELS.INFO;
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
- SINKS = sinks;
28
- THRESHOLD = threshold;
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
- THRESHOLD = threshold;
74
+ state().threshold = threshold;
43
75
  }
44
76
  export function activeSinks() {
45
- return SINKS;
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 this module context's sinks on first use (see setSinkInitializer).
52
- if (SINKS.length === 0 && initializer && !initAttempted) {
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 < THRESHOLD)
92
+ if (lvl < st.threshold)
58
93
  return;
59
- for (const sink of SINKS) {
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, `${this.path}.${prevKey}`);
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
- /** Build a file `Sink` wrapping a `RotatingFileWriter`. */
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 writer = new RotatingFileWriter(path, rotation, retention, render);
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",
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,