@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/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/api.d.ts +38 -0
- package/dist/api.js +80 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.js +60 -0
- package/dist/context.d.ts +88 -0
- package/dist/context.js +193 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +185 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +82 -0
- package/dist/metrics.d.ts +26 -0
- package/dist/metrics.js +111 -0
- package/dist/otel-start.d.ts +6 -0
- package/dist/otel-start.js +106 -0
- package/dist/otel.d.ts +26 -0
- package/dist/otel.js +173 -0
- package/dist/record.d.ts +29 -0
- package/dist/record.js +93 -0
- package/dist/render.d.ts +39 -0
- package/dist/render.js +131 -0
- package/dist/setup.d.ts +21 -0
- package/dist/setup.js +150 -0
- package/dist/sinks.d.ts +46 -0
- package/dist/sinks.js +243 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +19 -0
- package/package.json +86 -0
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
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/sinks.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
};
|