@diveinto/obs 1.0.1

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/dist/render.js ADDED
@@ -0,0 +1,131 @@
1
+ // Keys the schema owns. A user field that collides with one of these is renamed
2
+ // with a trailing underscore rather than silently clobbering the structural
3
+ // field (a `level` field must not overwrite the record's level).
4
+ const RESERVED = new Set([
5
+ "ts",
6
+ "level",
7
+ "trace_id",
8
+ "span_id",
9
+ "parent_span_id",
10
+ "logger",
11
+ "event",
12
+ "msg",
13
+ ]);
14
+ // Short, fixed-width-vocabulary level labels for the pretty line. Not padded:
15
+ // every field is separated by exactly one space, so a padded label would make
16
+ // the spacing look uneven. (The JSON renderer keeps the full level name.)
17
+ const LEVEL_LABEL = {
18
+ DEBUG: "DEBUG",
19
+ INFO: "INFO",
20
+ WARNING: "WARN",
21
+ ERROR: "ERROR",
22
+ CRITICAL: "CRIT",
23
+ };
24
+ // Separator between the human message and the key=value fields - one clear,
25
+ // consistent break instead of an ambiguous run of spaces. A colon would clash
26
+ // with the "event: message" colon, so a spaced hyphen reads cleanest. This is
27
+ // the same constant the agent uses, so both services' lines break identically.
28
+ const FIELD_SEP = " - ";
29
+ // Placeholder for an empty (missing) id, kept ASCII and 8 chars wide so the
30
+ // trace/span/parent triplet stays column-aligned even on un-traced lines.
31
+ const EMPTY_ID = ".".repeat(8);
32
+ /**
33
+ * Format an epoch-ms timestamp as ISO8601 UTC with millisecond precision.
34
+ * `Date.toISOString()` already yields exactly `YYYY-MM-DDTHH:MM:SS.mmmZ`, which
35
+ * matches the agent's hand-built format byte-for-byte.
36
+ */
37
+ function tsIso(ms) {
38
+ return new Date(ms).toISOString();
39
+ }
40
+ /** Baggage + per-call fields flattened, with reserved keys protected. */
41
+ function mergedFields(rec) {
42
+ const out = {};
43
+ for (const src of [rec.baggage, rec.fields]) {
44
+ for (const [k, v] of Object.entries(src)) {
45
+ out[RESERVED.has(k) ? `${k}_` : k] = v;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ /** Render one field value: quote strings only when whitespace would split the
51
+ * token, JSON-encode objects/arrays, and stringify everything else. */
52
+ function renderVal(v) {
53
+ if (typeof v === "string") {
54
+ return v.includes(" ") || v === "" ? `"${v}"` : v;
55
+ }
56
+ if (v !== null && typeof v === "object") {
57
+ return JSON.stringify(v);
58
+ }
59
+ return String(v);
60
+ }
61
+ function renderKv(fields) {
62
+ return Object.entries(fields)
63
+ .map(([k, v]) => `${k}=${renderVal(v)}`)
64
+ .join(" ");
65
+ }
66
+ function errorFields(e) {
67
+ const out = { "error.type": e.type, "error.message": e.message };
68
+ if (e.where)
69
+ out["error.where"] = e.where;
70
+ if (e.code !== undefined && e.code !== null)
71
+ out["error.code"] = e.code;
72
+ if (e.stack)
73
+ out["error.stack"] = e.stack;
74
+ return out;
75
+ }
76
+ function renderErrorBlock(e) {
77
+ const lines = [];
78
+ let head = ` error: ${e.type}: ${e.message}`;
79
+ if (e.code !== undefined && e.code !== null)
80
+ head += ` (code=${e.code})`;
81
+ lines.push(head);
82
+ if (e.where)
83
+ lines.push(` at ${e.where}`);
84
+ if (e.stack) {
85
+ for (const s of e.stack.replace(/\s+$/, "").split("\n"))
86
+ lines.push(` ${s}`);
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+ /**
91
+ * Single-line, greppable output for humans, AIs, journald, and the Vercel log
92
+ * view. One physical line per event (an error appends an indented stack block,
93
+ * which is inherently multi-line). The triplet is how you read the story: the
94
+ * `<` points from a child span to its parent, so matching one line's parent8 to
95
+ * another line's span8 rebuilds the tree - across services, since the agent
96
+ * emits the same shape.
97
+ */
98
+ export function renderPretty(rec) {
99
+ const t8 = (rec.trace.traceId || "").slice(0, 8) || EMPTY_ID;
100
+ const s8 = (rec.trace.spanId || "").slice(0, 8) || EMPTY_ID;
101
+ const p8 = (rec.trace.parentSpanId || "").slice(0, 8) || EMPTY_ID;
102
+ const level = LEVEL_LABEL[rec.level] ?? rec.level;
103
+ const body = rec.event && rec.msg ? `${rec.event}: ${rec.msg}` : rec.event || rec.msg;
104
+ // Exactly one space between every field, so the spacing is uniform across the
105
+ // whole line (timestamp, level, triplet, logger, event/message, then fields).
106
+ let line = `${tsIso(rec.tsMs)} ${level} [${t8}/${s8}<${p8}] ${rec.logger} ${body}`;
107
+ const kv = renderKv(mergedFields(rec));
108
+ if (kv)
109
+ line += `${FIELD_SEP}${kv}`;
110
+ if (rec.error)
111
+ line += "\n" + renderErrorBlock(rec.error);
112
+ return line;
113
+ }
114
+ /** One JSON object per line, stable schema, hand-rolled (no extra deps). */
115
+ export function renderJson(rec) {
116
+ const out = {
117
+ ts: tsIso(rec.tsMs),
118
+ level: rec.level,
119
+ trace_id: rec.trace.traceId,
120
+ span_id: rec.trace.spanId,
121
+ parent_span_id: rec.trace.parentSpanId,
122
+ logger: rec.logger,
123
+ msg: rec.msg,
124
+ };
125
+ if (rec.event)
126
+ out.event = rec.event;
127
+ Object.assign(out, mergedFields(rec));
128
+ if (rec.error)
129
+ Object.assign(out, errorFields(rec.error));
130
+ return JSON.stringify(out);
131
+ }
@@ -0,0 +1,21 @@
1
+ /** True on Vercel / AWS Lambda: no persistent, writable filesystem for files. */
2
+ export declare function isServerless(): boolean;
3
+ /** One file sink's resolved settings, or null when it is switched off. */
4
+ export type FileTarget = {
5
+ path: string;
6
+ rotation: string;
7
+ retention: number;
8
+ };
9
+ /**
10
+ * Resolve where the two files go, as a pure function of the environment so it
11
+ * can be tested without touching the filesystem. Defaults land under
12
+ * <state dir>/logs; every deploy's env points both at
13
+ * /var/log/<service>/ instead.
14
+ */
15
+ export declare function resolveFileTargets(env?: NodeJS.ProcessEnv): {
16
+ text: FileTarget | null;
17
+ json: FileTarget | null;
18
+ };
19
+ /** Test seam: let a test re-run setupLogging() with a different environment. */
20
+ export declare function resetLoggingForTests(): void;
21
+ export declare function setupLogging(): void;
package/dist/setup.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * One-call wiring: setupLogging().
3
+ *
4
+ * Called once from the app's `instrumentation.ts` (Next's `register()` boot
5
+ * hook) on the Node.js runtime, after `configure()`. Idempotent, because Next
6
+ * calls `register()` more than once across HMR and dev.
7
+ *
8
+ * Runtime portability is the load-bearing concern: a service may run
9
+ * self-hosted under systemd AND on Vercel. stdout is ALWAYS wired (journald
10
+ * captures it self-hosted; Vercel ships it from its log pipeline). The two
11
+ * rotating files are wired only when self-hosted, because a serverless
12
+ * filesystem is ephemeral or read-only.
13
+ *
14
+ * Env. `<PREFIX>` is the service's own `envPrefix`; the rest are generic and
15
+ * shared, so a fleet-wide change is one value everywhere:
16
+ * LOG_LEVEL trace|debug|info|warn|error|fatal (default info)
17
+ * LOG_FORMAT stdout renderer: pretty (default) | json
18
+ * <PREFIX>_LOG_TEXT_FILE pretty file: 1 (default path), 0/off, or a path
19
+ * <PREFIX>_LOG_TEXT_ROTATION daily (default) | hourly | <N>MB
20
+ * <PREFIX>_LOG_TEXT_RETENTION rotated files to keep (default 14)
21
+ * <PREFIX>_LOG_JSON_FILE / _ROTATION / _RETENTION same, for the json file
22
+ * <PREFIX>_VAR_DIR state dir (default /var/lib/<service>)
23
+ */
24
+ import { join } from "node:path";
25
+ import { logEvent } from "./api.js";
26
+ import { obsConfig } from "./config.js";
27
+ import { makeOtelLogSink, otelEnabled } from "./otel.js";
28
+ import { renderJson, renderPretty } from "./render.js";
29
+ import { configureSinks, makeFileSink, makeStdoutSink, setSinkInitializer } from "./sinks.js";
30
+ import { LEVELS } from "./types.js";
31
+ let configured = false;
32
+ // Register lazy self-init: the first log emitted in any module context (Next
33
+ // can isolate the instrumentation hook from the route handlers) runs
34
+ // setupLogging for THAT context, so request and domain logs are never
35
+ // silently dropped.
36
+ setSinkInitializer(() => setupLogging());
37
+ /** True on Vercel / AWS Lambda: no persistent, writable filesystem for files. */
38
+ export function isServerless() {
39
+ return !!(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME);
40
+ }
41
+ function stateDir(env) {
42
+ const cfg = obsConfig();
43
+ return env[`${cfg.envPrefix}_VAR_DIR`] || cfg.defaultVarDir;
44
+ }
45
+ /**
46
+ * Resolve where the two files go, as a pure function of the environment so it
47
+ * can be tested without touching the filesystem. Defaults land under
48
+ * <state dir>/logs; every deploy's env points both at
49
+ * /var/log/<service>/ instead.
50
+ */
51
+ export function resolveFileTargets(env = process.env) {
52
+ const cfg = obsConfig();
53
+ const p = cfg.envPrefix;
54
+ const dir = join(stateDir(env), "logs");
55
+ const text = fileSetting(env[`${p}_LOG_TEXT_FILE`]);
56
+ const jsonFile = fileSetting(env[`${p}_LOG_JSON_FILE`]);
57
+ return {
58
+ text: text
59
+ ? {
60
+ path: text === true ? join(dir, `${cfg.fileStem}.log`) : text,
61
+ rotation: (env[`${p}_LOG_TEXT_ROTATION`] || "daily").trim(),
62
+ retention: toInt(env[`${p}_LOG_TEXT_RETENTION`], 14),
63
+ }
64
+ : null,
65
+ json: jsonFile
66
+ ? {
67
+ path: jsonFile === true ? join(dir, `${cfg.fileStem}.jsonl`) : jsonFile,
68
+ rotation: (env[`${p}_LOG_JSON_ROTATION`] || "daily").trim(),
69
+ retention: toInt(env[`${p}_LOG_JSON_RETENTION`], 14),
70
+ }
71
+ : null,
72
+ };
73
+ }
74
+ function resolveLevel() {
75
+ const raw = (process.env.LOG_LEVEL || "info").trim().toUpperCase();
76
+ const map = {
77
+ TRACE: LEVELS.DEBUG,
78
+ DEBUG: LEVELS.DEBUG,
79
+ INFO: LEVELS.INFO,
80
+ WARN: LEVELS.WARNING,
81
+ WARNING: LEVELS.WARNING,
82
+ ERROR: LEVELS.ERROR,
83
+ FATAL: LEVELS.CRITICAL,
84
+ CRITICAL: LEVELS.CRITICAL,
85
+ };
86
+ return map[raw] ?? LEVELS.INFO;
87
+ }
88
+ function toInt(raw, fallback) {
89
+ const n = parseInt(String(raw ?? ""), 10);
90
+ return Number.isFinite(n) ? n : fallback;
91
+ }
92
+ /** Resolve a *_FILE env value: false (off), true (default path), or a path. */
93
+ function fileSetting(raw) {
94
+ const s = (raw ?? "1").trim();
95
+ const lower = s.toLowerCase();
96
+ if (["", "0", "off", "no", "false"].includes(lower))
97
+ return false;
98
+ if (["1", "on", "yes", "true", "default"].includes(lower))
99
+ return true;
100
+ return s;
101
+ }
102
+ /** Test seam: let a test re-run setupLogging() with a different environment. */
103
+ export function resetLoggingForTests() {
104
+ configured = false;
105
+ }
106
+ export function setupLogging() {
107
+ if (configured)
108
+ return;
109
+ configured = true;
110
+ const level = resolveLevel();
111
+ const stdoutFormat = (process.env.LOG_FORMAT || "pretty").trim().toLowerCase() === "json" ? "json" : "pretty";
112
+ // stdout is always on; its renderer follows LOG_FORMAT.
113
+ const sinks = [
114
+ makeStdoutSink(stdoutFormat === "json" ? renderJson : renderPretty, stdoutFormat),
115
+ ];
116
+ // The two rotating files are self-hosted only. They are format-locked
117
+ // (<stem>.log = pretty, <stem>.jsonl = json) regardless of LOG_FORMAT, so
118
+ // each file is always what its name promises.
119
+ const serverless = isServerless();
120
+ if (!serverless) {
121
+ const targets = resolveFileTargets();
122
+ if (targets.text) {
123
+ sinks.push(makeFileSink(targets.text.path, "pretty", renderPretty, targets.text.rotation, targets.text.retention));
124
+ }
125
+ if (targets.json) {
126
+ sinks.push(makeFileSink(targets.json.path, "json", renderJson, targets.json.rotation, targets.json.retention));
127
+ }
128
+ }
129
+ // When OTLP export is on, also ship logs to the backend's Logs view
130
+ // (trace-correlated), alongside stdout and the files.
131
+ if (otelEnabled()) {
132
+ const otelSink = makeOtelLogSink();
133
+ if (otelSink)
134
+ sinks.push(otelSink);
135
+ }
136
+ configureSinks(sinks, level);
137
+ const targets = sinks.map((s) => s.describe?.().target ?? s.name).join(", ");
138
+ // OTLP export is started from instrumentation.ts (registerOTel sets the
139
+ // global provider process-wide); here we only report whether it is on.
140
+ const otel = otelEnabled();
141
+ logEvent("obs.boot", `logging up: service=${obsConfig().service}, ` +
142
+ `mode=${serverless ? "serverless" : "self-hosted"}, ` +
143
+ `format=${stdoutFormat}, sinks=[${targets}], otel=${otel ? "on" : "off"}`, {
144
+ service: obsConfig().service,
145
+ mode: serverless ? "serverless" : "self-hosted",
146
+ format: stdoutFormat,
147
+ sink_count: sinks.length,
148
+ otel,
149
+ }, { logger: "obs" });
150
+ }
@@ -0,0 +1,46 @@
1
+ import { type ObsRecord, type Sink } from "./types.js";
2
+ export declare function configureSinks(sinks: Sink[], threshold: number): void;
3
+ export declare function setSinkInitializer(fn: () => void): void;
4
+ export declare function setThreshold(threshold: number): void;
5
+ export declare function activeSinks(): Sink[];
6
+ /** Dispatch one record to every eligible sink. Sinks swallow their own I/O
7
+ * errors, but we belt-and-brace with a try/catch so logging never throws into
8
+ * application code. */
9
+ export declare function emitToSinks(rec: ObsRecord): void;
10
+ /** A stdout sink in the given format. Stdout is always present: journald
11
+ * captures it self-hosted, and Vercel ships it serverless. */
12
+ export declare function makeStdoutSink(render: (rec: ObsRecord) => string, format: string): Sink;
13
+ /**
14
+ * A rotating append-only file writer.
15
+ *
16
+ * `rotation` is "daily" (UTC midnight) or "hourly" for time-based rolling, or
17
+ * "<N>MB" for size-based; `retention` is how many rotated files to keep. Time
18
+ * rotation renames `path` to `path.<period>` when the period changes; size
19
+ * rotation shifts `path.{N-1} -> path.N`. On any fs error the writer disables
20
+ * itself and reports once on stderr.
21
+ */
22
+ export declare class RotatingFileWriter {
23
+ private readonly path;
24
+ private readonly rotation;
25
+ private readonly retention;
26
+ private readonly render;
27
+ private stream;
28
+ private bytes;
29
+ private periodKey;
30
+ private disabled;
31
+ constructor(path: string, rotation: string, retention: number, render: (rec: ObsRecord) => string);
32
+ private open;
33
+ /** The bucket key for time-based rotation (empty for size-based). */
34
+ private currentPeriod;
35
+ private isSize;
36
+ write(rec: ObsRecord): void;
37
+ private maybeRotate;
38
+ private rotateTime;
39
+ private rotateSize;
40
+ /** Delete rotated files beyond `retention` (time-based keeps the newest by
41
+ * their date-sortable suffix). */
42
+ private prune;
43
+ private fail;
44
+ }
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;
package/dist/sinks.js ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Sink registry + the zero-dependency rotating file writer.
3
+ *
4
+ * A "sink" is one destination for log records: stdout (captured by journald
5
+ * self-hosted, or shipped by Vercel serverless) or a rotating file. The global
6
+ * LOG_LEVEL floor is applied here once, then each record is handed to every
7
+ * sink whose own minimum level it clears.
8
+ *
9
+ * The `RotatingFileWriter` is a small port of the agent's
10
+ * `TimedRotatingFileHandler` / `RotatingFileHandler` behaviour over `node:fs`,
11
+ * so we keep zero new dependencies. It NEVER crashes the app: a failed mkdir or
12
+ * write disables that one file and logs a note to stderr, leaving stdout (and
13
+ * the other file) running - the same degrade-don't-crash contract as the agent.
14
+ *
15
+ * This module is Node-only (it imports `node:fs`); it is reached solely through
16
+ * `setup.ts`, which only runs in the Node runtime.
17
+ */
18
+ import { createWriteStream, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, } from "node:fs";
19
+ import { basename, dirname, join } from "node:path";
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;
26
+ export function configureSinks(sinks, threshold) {
27
+ SINKS = sinks;
28
+ THRESHOLD = threshold;
29
+ }
30
+ // Lazy self-initialization. Next.js can bundle instrumentation.ts (which calls
31
+ // setupLogging) in a SEPARATE module graph from the route handlers, so a route
32
+ // handler may reach this module with SINKS still empty. setup.ts registers
33
+ // setupLogging here; the first emit in any context then configures the sinks for
34
+ // THAT context. Without this, only the instrumentation-context boot line is ever
35
+ // written - request/domain logs from the handler context would silently vanish.
36
+ let initializer = null;
37
+ let initAttempted = false;
38
+ export function setSinkInitializer(fn) {
39
+ initializer = fn;
40
+ }
41
+ export function setThreshold(threshold) {
42
+ THRESHOLD = threshold;
43
+ }
44
+ export function activeSinks() {
45
+ return SINKS;
46
+ }
47
+ /** Dispatch one record to every eligible sink. Sinks swallow their own I/O
48
+ * errors, but we belt-and-brace with a try/catch so logging never throws into
49
+ * application code. */
50
+ export function emitToSinks(rec) {
51
+ // Configure this module context's sinks on first use (see setSinkInitializer).
52
+ if (SINKS.length === 0 && initializer && !initAttempted) {
53
+ initAttempted = true;
54
+ initializer();
55
+ }
56
+ const lvl = LEVELS[rec.level];
57
+ if (lvl < THRESHOLD)
58
+ return;
59
+ for (const sink of SINKS) {
60
+ if (lvl < sink.minLevel)
61
+ continue;
62
+ try {
63
+ sink.write(rec);
64
+ }
65
+ catch {
66
+ /* a broken sink must not break the request */
67
+ }
68
+ }
69
+ }
70
+ /** A stdout sink in the given format. Stdout is always present: journald
71
+ * captures it self-hosted, and Vercel ships it serverless. */
72
+ export function makeStdoutSink(render, format) {
73
+ return {
74
+ name: "stdout",
75
+ minLevel: LEVELS.DEBUG,
76
+ write: (rec) => process.stdout.write(render(rec) + "\n"),
77
+ describe: () => ({ type: "stdout", target: "stdout", format }),
78
+ };
79
+ }
80
+ /**
81
+ * A rotating append-only file writer.
82
+ *
83
+ * `rotation` is "daily" (UTC midnight) or "hourly" for time-based rolling, or
84
+ * "<N>MB" for size-based; `retention` is how many rotated files to keep. Time
85
+ * rotation renames `path` to `path.<period>` when the period changes; size
86
+ * rotation shifts `path.{N-1} -> path.N`. On any fs error the writer disables
87
+ * itself and reports once on stderr.
88
+ */
89
+ export class RotatingFileWriter {
90
+ path;
91
+ rotation;
92
+ retention;
93
+ render;
94
+ stream = null;
95
+ bytes = 0;
96
+ periodKey = "";
97
+ disabled = false;
98
+ constructor(path, rotation, retention, render) {
99
+ this.path = path;
100
+ this.rotation = rotation;
101
+ this.retention = retention;
102
+ this.render = render;
103
+ try {
104
+ mkdirSync(dirname(path), { recursive: true });
105
+ this.open();
106
+ }
107
+ catch (e) {
108
+ this.fail(e);
109
+ }
110
+ }
111
+ open() {
112
+ this.stream = createWriteStream(this.path, { flags: "a" });
113
+ try {
114
+ this.bytes = statSync(this.path).size;
115
+ }
116
+ catch {
117
+ this.bytes = 0;
118
+ }
119
+ this.periodKey = this.currentPeriod();
120
+ }
121
+ /** The bucket key for time-based rotation (empty for size-based). */
122
+ currentPeriod() {
123
+ if (this.isSize())
124
+ return "";
125
+ const iso = new Date().toISOString();
126
+ // "hourly" -> YYYY-MM-DDTHH ; "daily" (default) -> YYYY-MM-DD. UTC, matching
127
+ // the agent's utc=True so rolled files are named consistently fleet-wide.
128
+ return this.rotation === "hourly" ? iso.slice(0, 13) : iso.slice(0, 10);
129
+ }
130
+ isSize() {
131
+ return this.rotation.toLowerCase().endsWith("mb");
132
+ }
133
+ write(rec) {
134
+ if (this.disabled || !this.stream)
135
+ return;
136
+ try {
137
+ const line = this.render(rec) + "\n";
138
+ const size = Buffer.byteLength(line);
139
+ this.maybeRotate(size);
140
+ this.stream.write(line);
141
+ this.bytes += size;
142
+ }
143
+ catch (e) {
144
+ this.fail(e);
145
+ }
146
+ }
147
+ maybeRotate(incoming) {
148
+ if (this.isSize()) {
149
+ const maxBytes = (parseInt(this.rotation, 10) || 100) * 1024 * 1024;
150
+ if (this.bytes > 0 && this.bytes + incoming > maxBytes)
151
+ this.rotateSize();
152
+ }
153
+ else {
154
+ const now = this.currentPeriod();
155
+ if (this.periodKey && now !== this.periodKey)
156
+ this.rotateTime(this.periodKey);
157
+ }
158
+ }
159
+ rotateTime(prevKey) {
160
+ this.stream?.end();
161
+ try {
162
+ renameSync(this.path, `${this.path}.${prevKey}`);
163
+ }
164
+ catch {
165
+ /* if the rename fails we just keep appending to the same file */
166
+ }
167
+ this.open();
168
+ this.prune();
169
+ }
170
+ rotateSize() {
171
+ this.stream?.end();
172
+ // Shift path.{retention-1} -> path.{retention} ... path.1 -> path.2, then
173
+ // path -> path.1. renameSync overwrites, so the count stays bounded.
174
+ for (let i = this.retention - 1; i >= 1; i--) {
175
+ try {
176
+ renameSync(`${this.path}.${i}`, `${this.path}.${i + 1}`);
177
+ }
178
+ catch {
179
+ /* a gap is harmless */
180
+ }
181
+ }
182
+ try {
183
+ renameSync(this.path, `${this.path}.1`);
184
+ }
185
+ catch {
186
+ /* nothing to roll yet */
187
+ }
188
+ this.open();
189
+ this.prune();
190
+ }
191
+ /** Delete rotated files beyond `retention` (time-based keeps the newest by
192
+ * their date-sortable suffix). */
193
+ prune() {
194
+ try {
195
+ const dir = dirname(this.path);
196
+ const base = basename(this.path) + ".";
197
+ const rotated = readdirSync(dir)
198
+ .filter((f) => f.startsWith(base))
199
+ .sort();
200
+ const excess = rotated.length - this.retention;
201
+ for (let i = 0; i < excess; i++) {
202
+ const stale = rotated[i];
203
+ if (!stale)
204
+ continue;
205
+ try {
206
+ unlinkSync(join(dir, stale));
207
+ }
208
+ catch {
209
+ /* best effort */
210
+ }
211
+ }
212
+ }
213
+ catch {
214
+ /* best effort */
215
+ }
216
+ }
217
+ fail(e) {
218
+ this.disabled = true;
219
+ this.stream = null;
220
+ try {
221
+ process.stderr.write(`[obs] file sink ${this.path} disabled (${String(e)}); stdout still active\n`);
222
+ }
223
+ catch {
224
+ /* nothing more we can do */
225
+ }
226
+ }
227
+ }
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);
231
+ return {
232
+ name: basename(path),
233
+ minLevel: LEVELS.DEBUG,
234
+ write: (rec) => writer.write(rec),
235
+ describe: () => ({
236
+ type: "file",
237
+ target: path,
238
+ format,
239
+ rotation,
240
+ retention,
241
+ }),
242
+ };
243
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shared shapes for the obs logging framework.
3
+ *
4
+ * Kept dependency-free and tiny so every other obs module (and the unit
5
+ * tests) can import these types without pulling in Node-only code. The
6
+ * field schema here is the contract the renderers and the JSON sink emit;
7
+ * it mirrors the Python agent's obs framework so logs from both services
8
+ * share one shape and one set of trace ids.
9
+ */
10
+ export declare const LEVELS: {
11
+ readonly DEBUG: 10;
12
+ readonly INFO: 20;
13
+ readonly WARNING: 30;
14
+ readonly ERROR: 40;
15
+ readonly CRITICAL: 50;
16
+ };
17
+ export type LevelName = keyof typeof LEVELS;
18
+ /** Loose level input we accept from callers: a name (any case) or a number. */
19
+ export type LevelInput = LevelName | Lowercase<LevelName> | "warn" | number;
20
+ /** Arbitrary structured facts attached to a log line (ids, counts, durations). */
21
+ export type Fields = Record<string, unknown>;
22
+ /** The error.* detail rendered on a failed line (built from a thrown value). */
23
+ export type ErrorInfo = {
24
+ type: string;
25
+ message: string;
26
+ where?: string;
27
+ code?: unknown;
28
+ stack?: string;
29
+ };
30
+ /**
31
+ * One fully-resolved log record, the neutral object both renderers read.
32
+ * `baggage` (ambient trace attributes) and `fields` (per-call facts) are kept
33
+ * separate and merged at render time, exactly like the agent, so reserved
34
+ * keys can be protected without the caller thinking about it.
35
+ */
36
+ export type ObsRecord = {
37
+ tsMs: number;
38
+ level: LevelName;
39
+ logger: string;
40
+ event?: string;
41
+ msg: string;
42
+ trace: {
43
+ traceId: string;
44
+ spanId: string;
45
+ parentSpanId: string;
46
+ };
47
+ baggage: Fields;
48
+ fields: Fields;
49
+ error?: ErrorInfo;
50
+ };
51
+ /** A destination for records (stdout or a rotating file). */
52
+ export type Sink = {
53
+ name: string;
54
+ /** Per-sink minimum level (the global LOG_LEVEL floor is applied first). */
55
+ minLevel: number;
56
+ /** Render + write one record; must never throw out (sinks swallow I/O errors). */
57
+ write: (rec: ObsRecord) => void;
58
+ /** Optional descriptor for the GET /logging-style introspection. */
59
+ describe?: () => {
60
+ type: string;
61
+ target: string;
62
+ format?: string;
63
+ rotation?: string;
64
+ retention?: number;
65
+ };
66
+ };
package/dist/types.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared shapes for the obs logging framework.
3
+ *
4
+ * Kept dependency-free and tiny so every other obs module (and the unit
5
+ * tests) can import these types without pulling in Node-only code. The
6
+ * field schema here is the contract the renderers and the JSON sink emit;
7
+ * it mirrors the Python agent's obs framework so logs from both services
8
+ * share one shape and one set of trace ids.
9
+ */
10
+ // Severity vocabulary + numeric ordering. Matches the agent's stdlib levels
11
+ // (DEBUG < INFO < WARNING < ERROR < CRITICAL) so a LOG_LEVEL floor means the
12
+ // same thing on both sides of the fleet.
13
+ export const LEVELS = {
14
+ DEBUG: 10,
15
+ INFO: 20,
16
+ WARNING: 30,
17
+ ERROR: 40,
18
+ CRITICAL: 50,
19
+ };