@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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * First-party metrics surface - a facade that currently exports NOTHING.
3
+ *
4
+ * The start path deliberately registers traces + logs only: registerOTel is
5
+ * called WITHOUT a metricReader, so no MeterProvider exists and
6
+ * @opentelemetry/api's `metrics.getMeter` hands back no-op instruments. Every
7
+ * call below therefore records into the void - kept as an API so call sites
8
+ * stay in place, but nothing metric-shaped leaves the process regardless of
9
+ * env vars (@vercel/otel does not read OTEL_METRICS_EXPORTER). Before ever
10
+ * wiring a metricReader: each metric needs a named consumer plus an export
11
+ * allowlist, and every unbounded attribute needs a cardinality cap. See the
12
+ * note in otel-start.ts for the incident that made this rule.
13
+ *
14
+ * CARDINALITY IS LOAD-BEARING. A metric attribute becomes a separate time series
15
+ * per distinct value, so attributes MUST be LOW-cardinality: kind / outcome /
16
+ * backend / route-template and the like - a small fixed vocabulary. NEVER pass a
17
+ * lab id, task id, dv_uuid, vm name, enduser, or any per-entity id: those are
18
+ * exactly right on spans and logs (where you want to find the one bad request)
19
+ * but toxic on metrics (they explode the series count and can OOM the backend).
20
+ * Because that judgement is the CALLER's, this helper takes an EXPLICIT attrs
21
+ * dict and deliberately does NOT auto-attach the obs baggage (trace ids, action
22
+ * name as a field, operator, ...) the way the log sink does - the baggage is
23
+ * high-cardinality by nature and would defeat the whole point.
24
+ *
25
+ * Node-only (imported via the obs facade); never reached by the Edge middleware.
26
+ */
27
+ import { obsConfig } from "./config.js";
28
+ import { metrics } from "@opentelemetry/api";
29
+ import { otelEnabled } from "./otel.js";
30
+ // Same meter name as the tracer/logger so all three signals file under one
31
+ // instrumentation scope in the backend.
32
+ // The meter name is the service's own, set by configure().
33
+ function meterName() {
34
+ return obsConfig().service;
35
+ }
36
+ // Instruments are meant to be created once and reused for the process lifetime
37
+ // (the SDK keys aggregation state by the instrument object). We lazily build
38
+ // each one on first use and cache it under its name so repeated calls from hot
39
+ // paths do not re-create instruments. Separate maps keep the types honest -
40
+ // a name is only ever one kind of instrument in practice.
41
+ const counters = new Map();
42
+ const histograms = new Map();
43
+ function getCounter(name) {
44
+ let c = counters.get(name);
45
+ if (!c) {
46
+ c = metrics.getMeter(meterName()).createCounter(name);
47
+ counters.set(name, c);
48
+ }
49
+ return c;
50
+ }
51
+ function getHistogram(name, unit) {
52
+ let h = histograms.get(name);
53
+ if (!h) {
54
+ // unit is advisory metadata (e.g. "ms") the backend can display; it is not a
55
+ // cardinality dimension, so it is safe and useful to set once at creation.
56
+ h = metrics.getMeter(meterName()).createHistogram(name, unit ? { unit } : undefined);
57
+ histograms.set(name, h);
58
+ }
59
+ return h;
60
+ }
61
+ /**
62
+ * Add to a monotonic counter (default +1). No-op when export is off. `attrs` must
63
+ * be low-cardinality (see the file header) - the type is OTel's Attributes, but
64
+ * the cardinality contract is on the caller, not the type system.
65
+ */
66
+ export function metricCounter(name, value = 1, attrs) {
67
+ if (!otelEnabled())
68
+ return;
69
+ getCounter(name).add(value, attrs);
70
+ }
71
+ /**
72
+ * Record one value into a histogram (latency, size, ...). No-op when export is
73
+ * off. Pass `unit` (e.g. "ms") on the first call to label the instrument; same
74
+ * low-cardinality rule applies to `attrs`.
75
+ */
76
+ export function metricHistogram(name, value, attrs, unit) {
77
+ if (!otelEnabled())
78
+ return;
79
+ getHistogram(name, unit).record(value, attrs);
80
+ }
81
+ /**
82
+ * Time `fn`, recording its elapsed milliseconds into the `name` histogram (unit
83
+ * "ms") with an `outcome` attribute of "ok" or "error". The outcome is folded
84
+ * into the histogram's own attributes (not a separate counter) so a single
85
+ * instrument carries both the latency distribution and the success/failure split
86
+ * - which is the standard RED-style shape an alert can read directly.
87
+ *
88
+ * When export is off this is a thin passthrough: it still runs `fn` (so callers
89
+ * can wrap unconditionally) but records nothing and adds no measurable overhead.
90
+ * It never swallows the error - it re-throws after stamping outcome="error", so
91
+ * the caller's control flow is unchanged.
92
+ */
93
+ export async function timed(name, attrs, fn) {
94
+ if (!otelEnabled())
95
+ return fn();
96
+ const start = Date.now();
97
+ // `finally` rather than a call in each branch: a throw of
98
+ // something that is not an Error, or a cancellation, still records a
99
+ // duration, and the histogram call exists once instead of twice.
100
+ let outcome = "ok";
101
+ try {
102
+ return await fn();
103
+ }
104
+ catch (err) {
105
+ outcome = "error";
106
+ throw err;
107
+ }
108
+ finally {
109
+ metricHistogram(name, Date.now() - start, { ...attrs, outcome }, "ms");
110
+ }
111
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Start OTLP export iff an endpoint is configured. Called once from
3
+ * instrumentation.ts on the Node runtime. Idempotent; returns whether export is
4
+ * live. Never throws - on any failure we stay first-party only.
5
+ */
6
+ export declare function startOtel(serviceName: string): Promise<boolean>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Starting OTLP export, kept in its own module on purpose.
3
+ *
4
+ * `startOtel()` dynamically imports @vercel/otel and the OTel SDK. Those are
5
+ * heavy and Node-only, and a static import would drag them into every bundle
6
+ * that touches the library, including the edge middleware bundle where they
7
+ * cannot load at all. Keeping the start path here means an app imports it once,
8
+ * from `instrumentation.ts`, and nothing else ever pulls it in.
9
+ *
10
+ * Published as the `@diveinto/obs/start` subpath for the same reason.
11
+ *
12
+ * Never throws: on any failure the service stays first-party only, still
13
+ * writing stdout and its two files. Losing export is not worth losing logs.
14
+ */
15
+ import { logs } from "@opentelemetry/api-logs";
16
+ // Module-local to the start path: `otelEnabled()` in otel.ts reads the env
17
+ // instead, because Next can run instrumentation.ts in a different module
18
+ // context from the route handlers, where a flag set here would read false.
19
+ let enabled = false;
20
+ let started = false;
21
+ /**
22
+ * Start OTLP export iff an endpoint is configured. Called once from
23
+ * instrumentation.ts on the Node runtime. Idempotent; returns whether export is
24
+ * live. Never throws - on any failure we stay first-party only.
25
+ */
26
+ export async function startOtel(serviceName) {
27
+ if (started)
28
+ return enabled;
29
+ started = true;
30
+ if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT)
31
+ return false;
32
+ try {
33
+ const { registerOTel } = await import("@vercel/otel");
34
+ // registerOTel reads OTEL_EXPORTER_OTLP_* (endpoint/headers/protocol) and
35
+ // OTEL_RESOURCE_ATTRIBUTES from the env. serviceName is the fallback label.
36
+ // TRACES + LOGS only, on purpose: no metricReader is passed here, so no
37
+ // metrics leave the process regardless of env vars. This is a scar, not a
38
+ // preference: auto-exporting metrics alongside traces once produced tens
39
+ // of millions of samples a month that nobody read. Do not add a
40
+ // metricReader without a named consumer, an export allowlist, and a
41
+ // cardinality cap on every attribute.
42
+ registerOTel({ serviceName });
43
+ await startLogExport(serviceName);
44
+ enabled = true;
45
+ return true;
46
+ }
47
+ catch (e) {
48
+ process.stderr.write(`[obs] OTel init failed (${String(e)}); staying first-party only\n`);
49
+ return false;
50
+ }
51
+ }
52
+ /**
53
+ * Set up the OTLP LOGS signal (separate from traces, which @vercel/otel owns).
54
+ * Installs a global LoggerProvider + OTLP/protobuf log exporter, so the log sink
55
+ * (makeOtelLogSink) can ship each record to the backend's Logs view, correlated
56
+ * to the active span. Best-effort; traces still work if this fails.
57
+ */
58
+ async function startLogExport(serviceName) {
59
+ try {
60
+ const { LoggerProvider, BatchLogRecordProcessor } = await import("@opentelemetry/sdk-logs");
61
+ const { OTLPLogExporter } = await import("@opentelemetry/exporter-logs-otlp-proto");
62
+ const { resourceFromAttributes, defaultResource } = await import("@opentelemetry/resources");
63
+ // Merge precedence matters: in OTel JS the merge ARGUMENT wins. defaultResource()
64
+ // carries the SDK's "unknown_service:node" default for service.name, so it must
65
+ // be the BASE and our attributes the argument - otherwise the backend files
66
+ // these logs under "unknown_service:node" (the original bug). Our attributes
67
+ // also pull service.version etc. from OTEL_RESOURCE_ATTRIBUTES so the LOGS
68
+ // resource matches the TRACES resource registerOTel builds.
69
+ const resource = defaultResource().merge(resourceFromAttributes(logResourceAttrs(serviceName)));
70
+ const provider = new LoggerProvider({
71
+ resource,
72
+ processors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
73
+ });
74
+ logs.setGlobalLoggerProvider(provider);
75
+ }
76
+ catch (e) {
77
+ process.stderr.write(`[obs] OTel log export init failed (${String(e)}); traces still export\n`);
78
+ }
79
+ }
80
+ /**
81
+ * Resource attributes for the LOGS provider. defaultResource() supplies the SDK
82
+ * defaults (telemetry.sdk.*) plus a placeholder service.name of
83
+ * "unknown_service:node"; we override service.name and add anything in
84
+ * OTEL_RESOURCE_ATTRIBUTES (service.version, deployment.environment, ...) so the
85
+ * LOGS land under the same service identity as the TRACES. These win the merge
86
+ * because they are passed as the merge argument (see startLogExport).
87
+ */
88
+ function logResourceAttrs(serviceName) {
89
+ const attrs = {};
90
+ const raw = process.env.OTEL_RESOURCE_ATTRIBUTES;
91
+ if (raw) {
92
+ for (const pair of raw.split(",")) {
93
+ const eq = pair.indexOf("=");
94
+ if (eq <= 0)
95
+ continue;
96
+ const k = pair.slice(0, eq).trim();
97
+ const v = pair.slice(eq + 1).trim();
98
+ if (k && v)
99
+ attrs[k] = v;
100
+ }
101
+ }
102
+ // Explicit env service name wins; else whatever the env attrs carried; else our
103
+ // label. Never the SDK's "unknown_service:node".
104
+ attrs["service.name"] = process.env.OTEL_SERVICE_NAME || attrs["service.name"] || serviceName;
105
+ return attrs;
106
+ }
package/dist/otel.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type Fields, type Sink } from "./types.js";
2
+ export declare function otelEnabled(): boolean;
3
+ /**
4
+ * A Sink that ships each obs record to the OTLP backend as a log, correlated to
5
+ * the active span (emit() reads the current context, and our records are emitted
6
+ * inside the request/domain span). Returns null when export is off. The internal
7
+ * export fetch does not pass back through our sinks, so there is no feedback loop.
8
+ */
9
+ export declare function makeOtelLogSink(): Sink | null;
10
+ /** The active OTel span's ids as W3C hex, or null when there is no valid span.
11
+ * Includes the span's PARENT id (same OTel id-space as spanId) so log records
12
+ * can reconstruct the tree, not just correlate by trace_id. */
13
+ export declare function currentIds(): {
14
+ traceId: string;
15
+ spanId: string;
16
+ parentSpanId: string;
17
+ } | null;
18
+ /**
19
+ * Open a SERVER span continuing the inbound W3C traceparent (extracted from the
20
+ * request headers), so this service's span nests under the caller's trace. A
21
+ * no-op passthrough when export is off.
22
+ */
23
+ export declare function withOtelServerSpan<T>(name: string, headers: Headers, attrs: Fields, fn: () => Promise<T>): Promise<T>;
24
+ /** Open a child span around a unit of work (domain story / outbound). A no-op
25
+ * passthrough when export is off. */
26
+ export declare function withOtelSpan<T>(name: string, attrs: Fields, fn: () => Promise<T>): Promise<T>;
package/dist/otel.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Optional OpenTelemetry export. Off unless OTEL_EXPORTER_OTLP_ENDPOINT is set.
3
+ *
4
+ * The first-party stdout/file logs are always the source of truth and work with
5
+ * no collector. This module is the bolt-on that ships spans to an OTLP backend
6
+ * (any OTLP-compatible collector). When ENABLED, OTel becomes the trace engine:
7
+ * - `@vercel/otel` registers the SDK + OTLP exporter (reading the standard
8
+ * OTEL_* env: endpoint, headers, protocol, resource attributes).
9
+ * - withOtelServerSpan() opens a SERVER span per request, CONTINUING an inbound
10
+ * W3C traceparent, so a trace started upstream continues here.
11
+ * - withOtelSpan() opens child spans for the domain story (order.place, ...).
12
+ * - context.ts reads the ACTIVE OTel span's ids, so every log line carries the
13
+ * same trace_id/span_id the backend shows, and traceHeaders() injects that
14
+ * same trace id on outbound calls (one trace across every hop).
15
+ *
16
+ * @opentelemetry/api is imported at module top (it is a tiny, side-effect-free
17
+ * facade); the heavy SDK (@vercel/otel) is imported lazily only when an endpoint
18
+ * is configured. This file is Node-only (reached via setup/instrumentation) and
19
+ * must never be imported by the Edge middleware.
20
+ */
21
+ import { context as otelContext, propagation, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
22
+ import { logs, SeverityNumber } from "@opentelemetry/api-logs";
23
+ import { obsConfig } from "./config.js";
24
+ import { LEVELS } from "./types.js";
25
+ // The tracer and OTLP logger name is the service's own, set by configure().
26
+ // It was a hardcoded constant in all four copies, which is exactly the kind
27
+ // of thing that makes a library a fork.
28
+ function otelName() {
29
+ return obsConfig().service;
30
+ }
31
+ export function otelEnabled() {
32
+ // Env-based (process-global) on purpose: Next can run instrumentation.ts in a
33
+ // different module context than the route handlers, so a per-module `enabled`
34
+ // flag set by startOtel would read false in handlers. registerOTel sets the
35
+ // OTel provider on globalThis (shared across contexts), so if the endpoint is
36
+ // configured we use the global OTel API everywhere; it degrades cleanly (no-op
37
+ // span -> currentIds null -> contextvar fallback) if the SDK did not start.
38
+ return !!process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
39
+ }
40
+ // stdlib level name -> OTel severity number.
41
+ const SEVERITY = {
42
+ DEBUG: SeverityNumber.DEBUG,
43
+ INFO: SeverityNumber.INFO,
44
+ WARNING: SeverityNumber.WARN,
45
+ ERROR: SeverityNumber.ERROR,
46
+ CRITICAL: SeverityNumber.FATAL,
47
+ };
48
+ // stdlib level name -> OTel CANONICAL severity text. The OTel ecosystem and
49
+ // the common backends use the short names WARN / FATAL, not the Python-style
50
+ // WARNING / CRITICAL this library carries internally. Emitting the short names
51
+ // keeps severity_text uniform across services, so one backend filter for
52
+ // "WARN" matches everything instead of having to also match "WARNING".
53
+ const SEVERITY_TEXT = {
54
+ DEBUG: "DEBUG",
55
+ INFO: "INFO",
56
+ WARNING: "WARN",
57
+ ERROR: "ERROR",
58
+ CRITICAL: "FATAL",
59
+ };
60
+ /**
61
+ * A Sink that ships each obs record to the OTLP backend as a log, correlated to
62
+ * the active span (emit() reads the current context, and our records are emitted
63
+ * inside the request/domain span). Returns null when export is off. The internal
64
+ * export fetch does not pass back through our sinks, so there is no feedback loop.
65
+ */
66
+ export function makeOtelLogSink() {
67
+ if (!otelEnabled())
68
+ return null;
69
+ const logger = logs.getLogger(otelName());
70
+ return {
71
+ name: "otlp",
72
+ minLevel: LEVELS.DEBUG,
73
+ write: (rec) => {
74
+ const attributes = { "logger.name": rec.logger };
75
+ if (rec.event)
76
+ attributes.event = rec.event;
77
+ for (const src of [rec.baggage, rec.fields]) {
78
+ for (const [k, v] of Object.entries(src))
79
+ attributes[k] = otelAttr(v);
80
+ }
81
+ logger.emit({
82
+ severityNumber: SEVERITY[rec.level] ?? SeverityNumber.INFO,
83
+ severityText: SEVERITY_TEXT[rec.level] ?? rec.level,
84
+ // Body is the human message only - the event key already rides as its own
85
+ // attribute, so prefixing it here is redundant and diverges from the
86
+ // agent, whose OTLP body is the bare message. Fall back to the event when
87
+ // there is no message (mirrors the agent's `message or event`).
88
+ body: rec.msg || rec.event || "",
89
+ attributes,
90
+ });
91
+ },
92
+ describe: () => ({ type: "otlp", target: "otlp/logs", format: "otlp" }),
93
+ };
94
+ }
95
+ /** The active OTel span's ids as W3C hex, or null when there is no valid span.
96
+ * Includes the span's PARENT id (same OTel id-space as spanId) so log records
97
+ * can reconstruct the tree, not just correlate by trace_id. */
98
+ export function currentIds() {
99
+ if (!otelEnabled())
100
+ return null;
101
+ const span = trace.getActiveSpan();
102
+ const sc = span?.spanContext();
103
+ if (!sc || !sc.traceId || sc.traceId === "0".repeat(32))
104
+ return null;
105
+ // Parent isn't on the public Span API - read it defensively off the concrete
106
+ // SDK span (ReadableSpan). SDK 2.x exposes parentSpanContext.spanId; SDK 1.x /
107
+ // @vercel/otel exposes parentSpanId. Degrade to "" (root span) if neither is
108
+ // present, so a missing/renamed field never throws.
109
+ const s = span;
110
+ const parentSpanId = s.parentSpanContext?.spanId ?? s.parentSpanId ?? "";
111
+ return { traceId: sc.traceId, spanId: sc.spanId, parentSpanId };
112
+ }
113
+ function otelAttr(v) {
114
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
115
+ return v;
116
+ return v === null || v === undefined ? "" : JSON.stringify(v);
117
+ }
118
+ function setAttrs(span, attrs) {
119
+ if (!span || !attrs)
120
+ return;
121
+ for (const [k, v] of Object.entries(attrs))
122
+ span.setAttribute(k, otelAttr(v));
123
+ }
124
+ /**
125
+ * Open a SERVER span continuing the inbound W3C traceparent (extracted from the
126
+ * request headers), so this service's span nests under the caller's trace. A
127
+ * no-op passthrough when export is off.
128
+ */
129
+ export async function withOtelServerSpan(name, headers, attrs, fn) {
130
+ if (!otelEnabled())
131
+ return fn();
132
+ const carrier = {};
133
+ headers.forEach((v, k) => {
134
+ carrier[k] = v;
135
+ });
136
+ const parent = propagation.extract(otelContext.active(), carrier);
137
+ const tracer = trace.getTracer(otelName());
138
+ return otelContext.with(parent, () => tracer.startActiveSpan(name, { kind: SpanKind.SERVER }, async (span) => {
139
+ setAttrs(span, attrs);
140
+ try {
141
+ const out = await fn();
142
+ span.end();
143
+ return out;
144
+ }
145
+ catch (e) {
146
+ span.recordException(e);
147
+ span.setStatus({ code: SpanStatusCode.ERROR });
148
+ span.end();
149
+ throw e;
150
+ }
151
+ }));
152
+ }
153
+ /** Open a child span around a unit of work (domain story / outbound). A no-op
154
+ * passthrough when export is off. */
155
+ export async function withOtelSpan(name, attrs, fn) {
156
+ if (!otelEnabled())
157
+ return fn();
158
+ const tracer = trace.getTracer(otelName());
159
+ return tracer.startActiveSpan(name, async (span) => {
160
+ setAttrs(span, attrs);
161
+ try {
162
+ const out = await fn();
163
+ span.end();
164
+ return out;
165
+ }
166
+ catch (e) {
167
+ span.recordException(e);
168
+ span.setStatus({ code: SpanStatusCode.ERROR });
169
+ span.end();
170
+ throw e;
171
+ }
172
+ });
173
+ }
@@ -0,0 +1,29 @@
1
+ import { type ErrorInfo, type Fields, type LevelInput, type LevelName, type ObsRecord } from "./types.js";
2
+ /** Normalise a loose level (name in any case, "warn", or a number) to a name. */
3
+ export declare function normalizeLevel(level: LevelInput | undefined): LevelName;
4
+ /** Turn a thrown value into the structured error.* detail (or undefined). */
5
+ export declare function toErrorInfo(err: unknown): ErrorInfo | undefined;
6
+ /** Build a record from the current trace context and emit it to the sinks. */
7
+ export declare function emit(level: LevelName, logger: string, event: string | undefined, msg: string, fields?: Fields, error?: unknown): void;
8
+ /**
9
+ * Test seam: capture records instead of emitting them.
10
+ *
11
+ * A test that asserts on log output should not have to configure sinks,
12
+ * write files or read stdout. Pass null to restore normal emission.
13
+ */
14
+ export declare function setObsRecorderForTests(recorder: ((rec: ObsRecord) => void) | null): void;
15
+ /** A trace-aware, event-first logger bound to a module name. */
16
+ export type Logger = {
17
+ readonly name: string;
18
+ /** Generic emit with an explicit level/error (the others delegate here). */
19
+ event(event: string, message: string, fields?: Fields, opts?: {
20
+ level?: LevelInput;
21
+ error?: unknown;
22
+ }): void;
23
+ debug(event: string, message: string, fields?: Fields): void;
24
+ info(event: string, message: string, fields?: Fields): void;
25
+ warn(event: string, message: string, fields?: Fields, error?: unknown): void;
26
+ error(event: string, message: string, fields?: Fields, error?: unknown): void;
27
+ };
28
+ /** Return a logger for `name` (e.g. "billing.invoice", "http.client"). */
29
+ export declare function getLogger(name: string): Logger;
package/dist/record.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Record assembly + the per-module logger.
3
+ *
4
+ * This is where a log call becomes a fully-resolved `ObsRecord`: it reads the
5
+ * live trace context (so every line carries trace/span ids and any bound
6
+ * baggage) and hands the record to the sink registry. It is the TypeScript
7
+ * counterpart of the agent's `obs/record.py` + the emit half of its adapter.
8
+ *
9
+ * Modules get a logger with `getLogger("<area>.<thing>")` and call event-first
10
+ * methods (`log.info("lab.launch.started", "launching lab ...", { lab })`) so
11
+ * the machine-stable event key and the human sentence always travel together.
12
+ */
13
+ import { current } from "./context.js";
14
+ import { emitToSinks } from "./sinks.js";
15
+ import { LEVELS } from "./types.js";
16
+ /** Normalise a loose level (name in any case, "warn", or a number) to a name. */
17
+ export function normalizeLevel(level) {
18
+ if (typeof level === "number") {
19
+ let best = "DEBUG";
20
+ for (const [name, value] of Object.entries(LEVELS)) {
21
+ if (level >= value)
22
+ best = name;
23
+ }
24
+ return best;
25
+ }
26
+ const s = String(level ?? "INFO").toUpperCase();
27
+ if (s === "WARN")
28
+ return "WARNING";
29
+ return (s in LEVELS ? s : "INFO");
30
+ }
31
+ /** Turn a thrown value into the structured error.* detail (or undefined). */
32
+ export function toErrorInfo(err) {
33
+ if (err === undefined || err === null)
34
+ return undefined;
35
+ if (err instanceof Error) {
36
+ return {
37
+ type: err.name || "Error",
38
+ message: err.message,
39
+ stack: err.stack,
40
+ code: err.code,
41
+ };
42
+ }
43
+ return { type: typeof err, message: String(err) };
44
+ }
45
+ let testRecorder = null;
46
+ /** Build a record from the current trace context and emit it to the sinks. */
47
+ export function emit(level, logger, event, msg, fields, error) {
48
+ const ctx = current();
49
+ const rec = {
50
+ tsMs: Date.now(),
51
+ level,
52
+ logger,
53
+ event,
54
+ msg: msg ?? "",
55
+ trace: { traceId: ctx.traceId, spanId: ctx.spanId, parentSpanId: ctx.parentSpanId },
56
+ baggage: ctx.baggage,
57
+ fields: fields ?? {},
58
+ error: toErrorInfo(error),
59
+ };
60
+ // Logging must never be the thing that takes a request down. A sink that
61
+ // throws (a full disk, a revoked permission) is swallowed here; the sink
62
+ // layer already self-disables a file it cannot write.
63
+ try {
64
+ if (testRecorder) {
65
+ testRecorder(rec);
66
+ return;
67
+ }
68
+ emitToSinks(rec);
69
+ }
70
+ catch {
71
+ /* a broken sink is not the caller's problem */
72
+ }
73
+ }
74
+ /**
75
+ * Test seam: capture records instead of emitting them.
76
+ *
77
+ * A test that asserts on log output should not have to configure sinks,
78
+ * write files or read stdout. Pass null to restore normal emission.
79
+ */
80
+ export function setObsRecorderForTests(recorder) {
81
+ testRecorder = recorder;
82
+ }
83
+ /** Return a logger for `name` (e.g. "billing.invoice", "http.client"). */
84
+ export function getLogger(name) {
85
+ return {
86
+ name,
87
+ event: (event, message, fields, opts) => emit(normalizeLevel(opts?.level ?? "INFO"), name, event, message, fields, opts?.error),
88
+ debug: (event, message, fields) => emit("DEBUG", name, event, message, fields),
89
+ info: (event, message, fields) => emit("INFO", name, event, message, fields),
90
+ warn: (event, message, fields, error) => emit("WARNING", name, event, message, fields, error),
91
+ error: (event, message, fields, error) => emit("ERROR", name, event, message, fields, error),
92
+ };
93
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The two log renderers: human-friendly "pretty" and machine "json".
3
+ *
4
+ * The line shape is a contract, not a style choice. The pretty form (the
5
+ * `[trace8/span8<parent8]` triplet, the single-space columns, the ` - `
6
+ * separator before the key=value block, the ISO8601-millisecond `Z` timestamp,
7
+ * the level labels) is matched by a sibling Python implementation, so a single
8
+ * `grep <trace8>` interleaves logs from services in both languages and they
9
+ * line up column-for-column. The JSON renderer emits the same key schema, so
10
+ * an aggregator sees one shape from all of them. Changing either is a
11
+ * breaking change for everything that reads these files.
12
+ *
13
+ * The field schema (defined once, here):
14
+ * ts ISO8601 UTC, millisecond precision (2026-06-28T15:42:01.118Z)
15
+ * level DEBUG | INFO | WARNING | ERROR | CRITICAL
16
+ * trace_id 32 hex chars, or "" outside a trace
17
+ * span_id 16 hex chars, or ""
18
+ * parent_span_id 16 hex chars, or ""
19
+ * logger logger name (billing.invoice, http.client, ...)
20
+ * event machine-stable beat name, omitted when not set
21
+ * msg the human sentence
22
+ * <fields> any extra key=value the call passed (includes dur_ms on a span finish)
23
+ * error.type / error.message / error.code / error.where / error.stack on errors
24
+ *
25
+ * Rendered punctuation is plain ASCII on purpose (no em/en dashes, no fancy
26
+ * glyphs) so logs stay clean in journald, files, the Vercel log view, and greps.
27
+ */
28
+ import type { ObsRecord } from "./types.js";
29
+ /**
30
+ * Single-line, greppable output for humans, AIs, journald, and the Vercel log
31
+ * view. One physical line per event (an error appends an indented stack block,
32
+ * which is inherently multi-line). The triplet is how you read the story: the
33
+ * `<` points from a child span to its parent, so matching one line's parent8 to
34
+ * another line's span8 rebuilds the tree - across services, since the agent
35
+ * emits the same shape.
36
+ */
37
+ export declare function renderPretty(rec: ObsRecord): string;
38
+ /** One JSON object per line, stable schema, hand-rolled (no extra deps). */
39
+ export declare function renderJson(rec: ObsRecord): string;