@diveinto/obs 1.0.2 → 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/config.d.ts CHANGED
@@ -71,11 +71,17 @@ type Resolved = Required<Pick<ObsConfig, "service" | "envPrefix" | "defaultVarDi
71
71
  };
72
72
  export declare function configure(cfg: ObsConfig): void;
73
73
  /**
74
- * The live config. Falling back rather than throwing is deliberate: a log
75
- * line emitted before `configure()` (an import-time warning, say) should
76
- * still come out, named honestly, instead of taking the process down.
74
+ * The live configuration, or a fallback.
75
+ *
76
+ * Falling back rather than throwing is deliberate: a log line emitted before
77
+ * `configure()` (an import-time warning, say) should still come out, named
78
+ * honestly, instead of taking the process down. The fallback is NOT cached,
79
+ * so a later `configure()` still wins, and `isConfigured()` lets the file
80
+ * sinks refuse to invent a path from it.
77
81
  */
78
82
  export declare function obsConfig(): Resolved;
83
+ /** Whether `configure()` has actually run. See `setupLogging()`. */
84
+ export declare function isConfigured(): boolean;
79
85
  /** Test seam: forget the configuration so a test can set a different one. */
80
86
  export declare function resetConfigForTests(): void;
81
87
  /** Read one of this service's own prefixed variables. */
package/dist/config.js CHANGED
@@ -14,7 +14,26 @@
14
14
  * service name is a no-op and a call with a DIFFERENT one is a mistake worth
15
15
  * hearing about.
16
16
  */
17
- let current = null;
17
+ /**
18
+ * The configuration lives on `globalThis`, NOT in a module-level variable.
19
+ *
20
+ * This is not defensiveness, it is a scar. Next can run `instrumentation.ts`
21
+ * in a DIFFERENT module context from the route handlers, so a module-level
22
+ * `let` set by `configure()` reads as unset in half the process. When that
23
+ * happened, the lazy sink initialiser re-ran `setupLogging()` in the second
24
+ * context, got the fallback configuration, tried to open
25
+ * `/var/lib/unconfigured/logs/...`, and self-disabled both file sinks. Every
26
+ * request log went to stdout only and the `.jsonl` and `.log` files silently
27
+ * stopped receiving anything, which is exactly the surface operators grep.
28
+ *
29
+ * `globalThis` is shared across those contexts, which is the same reason
30
+ * @vercel/otel puts its provider there and why `otelEnabled()` reads the
31
+ * environment rather than a flag.
32
+ */
33
+ const SLOT = Symbol.for("diveinto.obs.config");
34
+ function slot() {
35
+ return globalThis;
36
+ }
18
37
  function resolve(cfg) {
19
38
  const service = cfg.service.trim();
20
39
  if (!service)
@@ -35,24 +54,33 @@ function resolve(cfg) {
35
54
  }
36
55
  export function configure(cfg) {
37
56
  const next = resolve(cfg);
38
- if (current && current.service !== next.service) {
57
+ const held = slot()[SLOT];
58
+ if (held && held.service !== next.service) {
39
59
  // Two services in one process is not a thing this library supports, and
40
60
  // the symptom (logs filed under the wrong service) is miserable to chase.
41
- throw new Error(`[obs] already configured as '${current.service}'; cannot reconfigure as '${next.service}'`);
61
+ throw new Error(`[obs] already configured as '${held.service}'; cannot reconfigure as '${next.service}'`);
42
62
  }
43
- current = next;
63
+ slot()[SLOT] = next;
44
64
  }
45
65
  /**
46
- * The live config. Falling back rather than throwing is deliberate: a log
47
- * line emitted before `configure()` (an import-time warning, say) should
48
- * still come out, named honestly, instead of taking the process down.
66
+ * The live configuration, or a fallback.
67
+ *
68
+ * Falling back rather than throwing is deliberate: a log line emitted before
69
+ * `configure()` (an import-time warning, say) should still come out, named
70
+ * honestly, instead of taking the process down. The fallback is NOT cached,
71
+ * so a later `configure()` still wins, and `isConfigured()` lets the file
72
+ * sinks refuse to invent a path from it.
49
73
  */
50
74
  export function obsConfig() {
51
- return current ?? (current = resolve({ service: "unconfigured", envPrefix: "OBS" }));
75
+ return slot()[SLOT] ?? resolve({ service: "unconfigured", envPrefix: "OBS" });
76
+ }
77
+ /** Whether `configure()` has actually run. See `setupLogging()`. */
78
+ export function isConfigured() {
79
+ return slot()[SLOT] !== undefined;
52
80
  }
53
81
  /** Test seam: forget the configuration so a test can set a different one. */
54
82
  export function resetConfigForTests() {
55
- current = null;
83
+ delete slot()[SLOT];
56
84
  }
57
85
  /** Read one of this service's own prefixed variables. */
58
86
  export function envVar(name, env = process.env) {
package/dist/setup.js CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { join } from "node:path";
25
25
  import { logEvent } from "./api.js";
26
- import { obsConfig } from "./config.js";
26
+ import { isConfigured, obsConfig } from "./config.js";
27
27
  import { makeOtelLogSink, otelEnabled } from "./otel.js";
28
28
  import { renderJson, renderPretty } from "./render.js";
29
29
  import { configureSinks, makeFileSink, makeStdoutSink, setSinkInitializer } from "./sinks.js";
@@ -116,8 +116,18 @@ export function setupLogging() {
116
116
  // The two rotating files are self-hosted only. They are format-locked
117
117
  // (<stem>.log = pretty, <stem>.jsonl = json) regardless of LOG_FORMAT, so
118
118
  // each file is always what its name promises.
119
+ //
120
+ // Belt and braces after the globalThis fix: if this ever runs before
121
+ // configure() again, write to stdout and say so, rather than deriving a
122
+ // path from the fallback name. The old behaviour opened
123
+ // /var/lib/unconfigured/logs/, failed, self-disabled both files, and left
124
+ // an operator with a silently empty .jsonl.
119
125
  const serverless = isServerless();
120
- if (!serverless) {
126
+ if (!serverless && !isConfigured()) {
127
+ process.stderr.write("[obs] setupLogging() ran before configure(); writing to stdout only. " +
128
+ "Call configure() or createObs() first, from instrumentation.ts.\n");
129
+ }
130
+ if (!serverless && isConfigured()) {
121
131
  const targets = resolveFileTargets();
122
132
  if (targets.text) {
123
133
  sinks.push(makeFileSink(targets.text.path, "pretty", renderPretty, targets.text.rotation, targets.text.retention));
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.2",
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,