@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 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 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.
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 three for `_JSON_`. A
97
- `*_FILE` value is a path, or `1` for the default path, or `0`/`off`.
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
- /** Build a file `Sink` wrapping a `RotatingFileWriter`. */
46
- export declare function makeFileSink(path: string, format: string, render: (rec: ObsRecord) => string, rotation: string, retention: number): Sink;
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
- // 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 {
@@ -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
- else {
154
- const now = this.currentPeriod();
155
- if (this.periodKey && now !== this.periodKey)
156
- this.rotateTime(this.periodKey);
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, `${this.path}.${prevKey}`);
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
- /** Build a file `Sink` wrapping a `RotatingFileWriter`. */
229
- export function makeFileSink(path, format, render, rotation, retention) {
230
- const writer = new RotatingFileWriter(path, rotation, retention, render);
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",
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,