@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,193 @@
1
+ /**
2
+ * Trace-context core.
3
+ *
4
+ * This is the TypeScript counterpart of the agent's `obs/context.py`. It owns
5
+ * the ambient trace and span identifiers and nothing else - no logging, no
6
+ * formatting, no OpenTelemetry - so it stays easy to understand and to drop an
7
+ * optional OTel exporter on top of later.
8
+ *
9
+ * How the ids flow (identical model to the agent, so the two services stitch
10
+ * into one trace):
11
+ *
12
+ * - An inbound request (a browser, or another service) opens a ROOT trace
13
+ * with `runWithTrace()`. If the caller sent a W3C `traceparent`, we CONTINUE
14
+ * that trace id (and point our first span at the caller's span); otherwise
15
+ * we mint a fresh 128-bit trace id.
16
+ * - Any nested unit of work opens a CHILD span with `runWithSpan()`; the
17
+ * current span becomes the new span's parent, so the lines a child emits
18
+ * point back at their caller.
19
+ * - When this service calls another, `traceHeaders()` serialises the
20
+ * current ids into a `traceparent` header; the agent continues the SAME
21
+ * trace id, so one `grep <trace8>` lines up logs from both services.
22
+ *
23
+ * Storage is `AsyncLocalStorage` (the Node equivalent of Python's contextvars):
24
+ * the store is carried automatically across `await`, `then`, timers, and
25
+ * `queueMicrotask`, so a trace opened in a request handler survives every
26
+ * `await agent.fetch(...)` without us threading anything through.
27
+ *
28
+ * IMPORTANT runtime boundary: AsyncLocalStorage and `node:crypto` are Node-only.
29
+ * The Edge middleware (`src/proxy.ts`) must NOT import this module; it mints a
30
+ * `traceparent` header with Web Crypto instead, and the Node side reads it back
31
+ * here via `parseTraceparent`.
32
+ */
33
+ import { AsyncLocalStorage } from "node:async_hooks";
34
+ import { randomBytes } from "node:crypto";
35
+ import { currentIds as otelCurrentIds } from "./otel.js";
36
+ // Empty-id state means "no active trace" (e.g. a log at module-import time). The
37
+ // renderers show empty ids as a row of dots so those lines are obvious.
38
+ const EMPTY = {
39
+ traceId: "",
40
+ spanId: "",
41
+ parentSpanId: "",
42
+ sampled: true,
43
+ baggage: {},
44
+ };
45
+ const als = new AsyncLocalStorage();
46
+ /** Mint a fresh 128-bit trace id as 32 lowercase hex chars (W3C format). */
47
+ export function newTraceId() {
48
+ return randomBytes(16).toString("hex");
49
+ }
50
+ /** Mint a fresh 64-bit span id as 16 lowercase hex chars (W3C format). */
51
+ export function newSpanId() {
52
+ return randomBytes(8).toString("hex");
53
+ }
54
+ /** The current trace state. When OTLP export is live, the active OTel span is
55
+ * authoritative for the ids (so logs match the backend exactly and traceHeaders
56
+ * injects the OTel trace); baggage stays in our AsyncLocalStorage store.
57
+ * Outside any trace, the empty stand-in. */
58
+ export function current() {
59
+ const store = als.getStore() ?? EMPTY;
60
+ const ids = otelCurrentIds();
61
+ if (ids) {
62
+ // Adopt the OTel span's parent too (same id-space as spanId), so logs carry
63
+ // the tree - not just the trace_id. Root spans report "" (no parent).
64
+ return {
65
+ ...store,
66
+ traceId: ids.traceId,
67
+ spanId: ids.spanId,
68
+ parentSpanId: ids.parentSpanId,
69
+ };
70
+ }
71
+ return store;
72
+ }
73
+ export function currentTraceId() {
74
+ return current().traceId;
75
+ }
76
+ export function currentSpanId() {
77
+ return current().spanId;
78
+ }
79
+ export function currentBaggage() {
80
+ return current().baggage;
81
+ }
82
+ export function isSampled() {
83
+ return current().sampled;
84
+ }
85
+ /**
86
+ * Run `fn` inside a brand-new ROOT trace.
87
+ *
88
+ * `seed.traceId` continues an inbound trace (e.g. a `traceparent` from the
89
+ * upstream caller); when absent a fresh id is minted. `seed.parentSpanId` is the
90
+ * caller's span id from the inbound header, so our first span points back at the
91
+ * remote caller and the tree spans both services.
92
+ *
93
+ * Unlike the agent's save/restore pattern, `als.run` restores the parent store
94
+ * automatically when `fn` returns or throws - the runtime does the "finally" for
95
+ * us.
96
+ */
97
+ export function runWithTrace(seed, fn) {
98
+ const store = {
99
+ traceId: seed.traceId || newTraceId(),
100
+ spanId: newSpanId(),
101
+ parentSpanId: seed.parentSpanId || "",
102
+ sampled: seed.sampled ?? true,
103
+ baggage: {},
104
+ };
105
+ return als.run(store, fn);
106
+ }
107
+ /**
108
+ * Run `fn` inside a CHILD span of the current trace (the current span becomes
109
+ * the parent). If no trace is active we mint one defensively, so a stray span
110
+ * still produces a usable (if un-parented) trace rather than blank ids.
111
+ *
112
+ * The child copies the parent's baggage by value, so a child's `bind()` cannot
113
+ * leak back up into the parent - matching the agent's copy-on-write contract.
114
+ */
115
+ export function runWithSpan(fn) {
116
+ const parent = current();
117
+ const store = {
118
+ traceId: parent.traceId || newTraceId(),
119
+ spanId: newSpanId(),
120
+ parentSpanId: parent.spanId || "",
121
+ sampled: parent.sampled,
122
+ baggage: { ...parent.baggage },
123
+ };
124
+ return als.run(store, fn);
125
+ }
126
+ /**
127
+ * Stamp domain attributes onto the current scope so they appear on every later
128
+ * line. This deliberately MUTATES the live store's baggage (the one object
129
+ * AsyncLocalStorage hands back for the whole scope); it is a no-op outside a
130
+ * trace. Child scopes snapshot baggage at creation, so this never leaks across
131
+ * sibling requests.
132
+ */
133
+ export function bind(attrs) {
134
+ const store = als.getStore();
135
+ if (!store)
136
+ return;
137
+ store.baggage = { ...store.baggage, ...attrs };
138
+ }
139
+ /**
140
+ * Parse a W3C `traceparent` header into `{ traceId, parentSpanId }`.
141
+ *
142
+ * Format: `version-traceid-spanid-flags`, e.g.
143
+ * `00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01`. Returns null for
144
+ * anything malformed so the caller mints a fresh root rather than trusting junk;
145
+ * the all-zero ids are explicitly invalid per the spec. This is the exact mirror
146
+ * of the agent's `parse_traceparent`, so each side accepts the other's output.
147
+ */
148
+ export function parseTraceparent(header) {
149
+ if (!header)
150
+ return null;
151
+ const parts = header.split("-");
152
+ if (parts.length !== 4)
153
+ return null;
154
+ const traceId = parts[1];
155
+ const spanId = parts[2];
156
+ if (!traceId || !spanId)
157
+ return null;
158
+ if (traceId.length !== 32 || spanId.length !== 16)
159
+ return null;
160
+ if (!isHex(traceId) || !isHex(spanId))
161
+ return null;
162
+ if (traceId === "0".repeat(32) || spanId === "0".repeat(16))
163
+ return null;
164
+ return { traceId: traceId.toLowerCase(), parentSpanId: spanId.toLowerCase() };
165
+ }
166
+ /**
167
+ * Accept a plain UUID as a trace id.
168
+ *
169
+ * Some front ends pass a correlation UUID rather than a W3C
170
+ * `traceparent`, and a 32-hex UUID with the dashes stripped IS a valid trace
171
+ * id. Taking it means a user's journey is one trace from the front end through
172
+ * the launch to the lab, instead of two unconnected halves.
173
+ *
174
+ * Returns null for anything that is not 32 hex characters, and for the
175
+ * all-zero id, which W3C defines as "no trace".
176
+ */
177
+ export function traceIdFromCorrelationId(raw) {
178
+ if (!raw)
179
+ return null;
180
+ const hex = raw.replace(/-/g, "").toLowerCase();
181
+ if (hex.length !== 32 || !isHex(hex))
182
+ return null;
183
+ if (hex === "0".repeat(32))
184
+ return null;
185
+ return hex;
186
+ }
187
+ /** Serialise ids into a W3C `traceparent` string for an outbound header. */
188
+ export function formatTraceparent(traceId, spanId, sampled) {
189
+ return `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
190
+ }
191
+ function isHex(s) {
192
+ return s.length > 0 && /^[0-9a-fA-F]+$/.test(s);
193
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { Fields } from "./types.js";
2
+ /**
3
+ * The headers to attach to an outbound request to the agent so it CONTINUES
4
+ * this trace. Returns `{ traceparent }` when a trace is active, else `{}`. The
5
+ * agent's `parse_traceparent` accepts this exact format, and its request span
6
+ * becomes a child of our current span - so caller and callee are one tree.
7
+ */
8
+ export declare function traceHeaders(): Record<string, string>;
9
+ /**
10
+ * Where this request's trace continues from.
11
+ *
12
+ * A W3C `traceparent` always wins: it is the standard and it is what the
13
+ * middleware and other services send. Only when there is none does the
14
+ * service's own `traceSeed` get a say, which is how a service continues a
15
+ * trace from a front end's correlation UUID rather than starting a second,
16
+ * unconnected one. A service that configures no seed gets
17
+ * `traceparent` or a fresh root, which is the behaviour three of the four
18
+ * copies had.
19
+ */
20
+ export declare function resolveTraceSeed(req: Request): {
21
+ traceId?: string;
22
+ parentSpanId?: string;
23
+ };
24
+ /**
25
+ * Wrap a request handler in a trace. Continues an inbound `traceparent` (set by
26
+ * the Edge middleware, or sent by an upstream service) so the whole chain shares
27
+ * one trace_id; otherwise mints a fresh root. Logs request.received (debug) and
28
+ * request.completed (info, or error on 5xx), and best-effort sets X-Trace-Id on
29
+ * the response (wrapped in try/catch - a streaming response may forbid header
30
+ * mutation, and that must never break the request).
31
+ */
32
+ export declare function withApiTrace<T extends Response>(req: Request, handler: () => Promise<T>, opts?: {
33
+ fields?: Fields;
34
+ }): Promise<T>;
35
+ /**
36
+ * Wrap a server action (which has no NextRequest) in a FRESH root trace named
37
+ * after the action, so each action invocation is its own story. Binds the
38
+ * action name and any extra fields, logs start/finish/failed, and records two
39
+ * domain metrics that complement the span-derived RED with a clean, alertable
40
+ * action signal:
41
+ * - diveinto.action.duration: histogram of elapsed ms
42
+ * - diveinto.action.count: counter of invocations
43
+ * Both carry only {action, outcome} - the action NAME is a fixed, low-cardinality
44
+ * set and outcome is ok/error, so the time-series count stays bounded. We pass
45
+ * those attrs EXPLICITLY (not `fields`) on purpose: the bound `fields` carry
46
+ * per-entity ids / operator / email, which belong on the span and the logs but
47
+ * would explode the metric cardinality. See metrics.ts for the why.
48
+ */
49
+ export declare function withAction<T>(name: string, fn: () => Promise<T>, fields?: Fields): Promise<T>;
package/dist/http.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Next.js-aware glue between the runtime-agnostic obs core and the App Router.
3
+ *
4
+ * Kept separate from the rest of `obs/` so the core stays framework-free (and
5
+ * trivially unit-testable). This module is Node-only and is imported solely by
6
+ * Node route handlers, server actions, and the agent clients - NEVER by the Edge
7
+ * middleware (`src/proxy.ts`), which would fail to bundle `node:*`.
8
+ *
9
+ * It provides three seams:
10
+ * - withApiTrace: wrap a request handler so it opens/continues a trace, logs
11
+ * request.received/completed, and returns X-Trace-Id.
12
+ * - withAction: wrap a server action so it runs in a fresh root trace.
13
+ * - traceHeaders: the outbound header that propagates the trace into the agent
14
+ * (the cross-service win - one trace_id spans caller -> callee -> work).
15
+ */
16
+ import { bind, currentSpanId, currentTraceId, formatTraceparent, isSampled, parseTraceparent, runWithTrace, } from "./context.js";
17
+ import { logEvent } from "./api.js";
18
+ import { obsConfig } from "./config.js";
19
+ import { metricCounter, metricHistogram } from "./metrics.js";
20
+ import { otelEnabled, withOtelServerSpan, withOtelSpan } from "./otel.js";
21
+ /**
22
+ * The headers to attach to an outbound request to the agent so it CONTINUES
23
+ * this trace. Returns `{ traceparent }` when a trace is active, else `{}`. The
24
+ * agent's `parse_traceparent` accepts this exact format, and its request span
25
+ * becomes a child of our current span - so caller and callee are one tree.
26
+ */
27
+ export function traceHeaders() {
28
+ const traceId = currentTraceId();
29
+ if (!traceId)
30
+ return {};
31
+ return { traceparent: formatTraceparent(traceId, currentSpanId(), isSampled()) };
32
+ }
33
+ /**
34
+ * Where this request's trace continues from.
35
+ *
36
+ * A W3C `traceparent` always wins: it is the standard and it is what the
37
+ * middleware and other services send. Only when there is none does the
38
+ * service's own `traceSeed` get a say, which is how a service continues a
39
+ * trace from a front end's correlation UUID rather than starting a second,
40
+ * unconnected one. A service that configures no seed gets
41
+ * `traceparent` or a fresh root, which is the behaviour three of the four
42
+ * copies had.
43
+ */
44
+ export function resolveTraceSeed(req) {
45
+ const fromParent = parseTraceparent(req.headers.get("traceparent"));
46
+ if (fromParent)
47
+ return fromParent;
48
+ return obsConfig().traceSeed?.(req) ?? {};
49
+ }
50
+ /**
51
+ * Wrap a request handler in a trace. Continues an inbound `traceparent` (set by
52
+ * the Edge middleware, or sent by an upstream service) so the whole chain shares
53
+ * one trace_id; otherwise mints a fresh root. Logs request.received (debug) and
54
+ * request.completed (info, or error on 5xx), and best-effort sets X-Trace-Id on
55
+ * the response (wrapped in try/catch - a streaming response may forbid header
56
+ * mutation, and that must never break the request).
57
+ */
58
+ export async function withApiTrace(req, handler, opts) {
59
+ const method = req.method;
60
+ let path = "";
61
+ try {
62
+ path = new URL(req.url).pathname;
63
+ }
64
+ catch {
65
+ /* leave path empty if the URL is unparseable */
66
+ }
67
+ const fields = { "http.method": method, "http.path": path, ...(opts?.fields ?? {}) };
68
+ // The first-party logging body. currentTraceId() is OTel-aware, so when export
69
+ // is on this logs (and stamps X-Trace-Id with) the OTel trace id.
70
+ const inner = () => runWithTrace(resolveTraceSeed(req), async () => {
71
+ bind(fields);
72
+ const start = Date.now();
73
+ logEvent("request.received", `${method} ${path}`, undefined, {
74
+ level: "debug",
75
+ logger: "http",
76
+ });
77
+ try {
78
+ const res = await handler();
79
+ const ms = Date.now() - start;
80
+ // One RED histogram per request. The label is the TEMPLATE, not the
81
+ // path: `/api/labs/abc123` and `/api/labs/def456` are one time series,
82
+ // not two. A service that configures no routeTemplate has few enough
83
+ // dynamic routes for the raw path to be safe; one that does not is how
84
+ // a metrics backend ends up with a million series.
85
+ metricHistogram("serve.request.duration", ms, {
86
+ route: routeLabel(path),
87
+ method,
88
+ status_class: `${Math.floor(res.status / 100)}xx`,
89
+ }, "ms");
90
+ logEvent("request.completed", `${method} ${path} -> ${res.status} in ${ms} ms`, { "http.status": res.status, dur_ms: ms }, {
91
+ // A service's own high-frequency machine traffic (cron polls and
92
+ // health checks firing every minute) is noise at
93
+ // INFO: the real work each does is logged separately. Those
94
+ // prefixes are configured per service, because one service's
95
+ // background poll is another's main path. A 5xx still surfaces as
96
+ // ERROR whatever the prefix.
97
+ level: res.status >= 500 ? "error" : isQuiet(path) ? "debug" : "info",
98
+ logger: "http",
99
+ });
100
+ try {
101
+ res.headers.set("X-Trace-Id", currentTraceId());
102
+ }
103
+ catch {
104
+ /* immutable/streaming response headers - best effort only */
105
+ }
106
+ return res;
107
+ }
108
+ catch (err) {
109
+ const ms = Date.now() - start;
110
+ logEvent("request.failed", `${method} ${path} raised after ${ms} ms`, { dur_ms: ms }, { level: "error", logger: "http", error: err });
111
+ throw err;
112
+ }
113
+ });
114
+ // When OTLP export is on, open a real SERVER span that CONTINUES the inbound
115
+ // W3C traceparent, then run the logging body inside it (which now adopts the
116
+ // OTel ids). When off, just run the body on the AsyncLocalStorage path.
117
+ return otelEnabled() ? withOtelServerSpan(`${method} ${path}`, req.headers, fields, inner) : inner();
118
+ }
119
+ /**
120
+ * Wrap a server action (which has no NextRequest) in a FRESH root trace named
121
+ * after the action, so each action invocation is its own story. Binds the
122
+ * action name and any extra fields, logs start/finish/failed, and records two
123
+ * domain metrics that complement the span-derived RED with a clean, alertable
124
+ * action signal:
125
+ * - diveinto.action.duration: histogram of elapsed ms
126
+ * - diveinto.action.count: counter of invocations
127
+ * Both carry only {action, outcome} - the action NAME is a fixed, low-cardinality
128
+ * set and outcome is ok/error, so the time-series count stays bounded. We pass
129
+ * those attrs EXPLICITLY (not `fields`) on purpose: the bound `fields` carry
130
+ * per-entity ids / operator / email, which belong on the span and the logs but
131
+ * would explode the metric cardinality. See metrics.ts for the why.
132
+ */
133
+ export async function withAction(name, fn, fields) {
134
+ const inner = () => runWithTrace({}, async () => {
135
+ bind({ "action.name": name, ...(fields ?? {}) });
136
+ const start = Date.now();
137
+ logEvent("action.started", `server action ${name}`, undefined, { logger: "action" });
138
+ try {
139
+ const out = await fn();
140
+ const ms = Date.now() - start;
141
+ recordActionMetrics(name, "ok", ms);
142
+ logEvent("action.completed", `server action ${name} ok in ${ms} ms`, { dur_ms: ms }, { logger: "action" });
143
+ return out;
144
+ }
145
+ catch (err) {
146
+ const ms = Date.now() - start;
147
+ recordActionMetrics(name, "error", ms);
148
+ logEvent("action.failed", `server action ${name} failed after ${ms} ms`, { dur_ms: ms }, { level: "error", logger: "action", error: err });
149
+ throw err;
150
+ }
151
+ });
152
+ // A server action has no inbound request, so when export is on this opens a
153
+ // fresh root OTel span (startActiveSpan with no active parent).
154
+ return otelEnabled() ? withOtelSpan(`action ${name}`, { "action.name": name, ...(fields ?? {}) }, inner) : inner();
155
+ }
156
+ /**
157
+ * Record the action duration histogram + invocation counter with the SAME
158
+ * low-cardinality attrs. Kept as one helper so the ok and error paths cannot
159
+ * drift apart, and so the cardinality contract lives in exactly one place: the
160
+ * only attributes are the fixed action name and a binary outcome - nothing
161
+ * per-entity. metricCounter/metricHistogram are themselves no-ops when export
162
+ * is off, so this is free in the common (no-collector) case.
163
+ */
164
+ function recordActionMetrics(name, outcome, ms) {
165
+ const attrs = { action: name, outcome };
166
+ metricHistogram("diveinto.action.duration", ms, attrs, "ms");
167
+ metricCounter("diveinto.action.count", 1, attrs);
168
+ }
169
+ /** Apply the service's route template, if it configured one. */
170
+ function routeLabel(path) {
171
+ const tpl = obsConfig().routeTemplate;
172
+ if (!tpl)
173
+ return path;
174
+ try {
175
+ return tpl(path);
176
+ }
177
+ catch {
178
+ // A bad template must not cost us the request or the metric.
179
+ return path;
180
+ }
181
+ }
182
+ /** True when this path's completion should log at debug rather than info. */
183
+ function isQuiet(path) {
184
+ return obsConfig().quietPathPrefixes.some((p) => path.startsWith(p));
185
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @diveinto/obs - structured logging, tracing and metrics for Next.js and
3
+ * Node services.
4
+ *
5
+ * One record shape, written to stdout and to two rotating files, with W3C
6
+ * trace context threaded through it, and optional OpenTelemetry export that
7
+ * is off until you set an endpoint. It grew out of consolidating four
8
+ * hand-maintained copies of the same library across four services, each of
9
+ * which had drifted and grown something the others lacked; this is the union
10
+ * of the four, and every one of those differences is pinned by a test.
11
+ *
12
+ * WHAT IT IS NOT: this module is runtime-agnostic and must stay importable
13
+ * from the Edge runtime. The Node-only pieces live behind their own imports:
14
+ * the OTLP start path is `@diveinto/obs/start`, and the file sinks are only
15
+ * wired by `setupLogging()`, which an app calls from `instrumentation.ts` on
16
+ * the Node runtime. Do not add a static `node:*` import here.
17
+ *
18
+ * Usage, once, from the app's instrumentation.ts:
19
+ *
20
+ * import { createObs } from "@diveinto/obs";
21
+ * export const obs = createObs({
22
+ * service: "my-service",
23
+ * envPrefix: "MYSVC",
24
+ * });
25
+ *
26
+ * and everywhere else, plain named imports:
27
+ *
28
+ * import { getLogger, withApiTrace, traceHeaders } from "@diveinto/obs";
29
+ *
30
+ * The record shape is the contract and is frozen:
31
+ * ts level trace_id span_id parent_span_id logger event msg + context
32
+ * A sibling Python implementation writes the same shape, which is what lets
33
+ * one grep follow a trace across services in either language.
34
+ */
35
+ import { type ObsConfig } from "./config.js";
36
+ import { bind, currentSpanId, currentTraceId, runWithTrace } from "./context.js";
37
+ import { logEvent, withSpan, withTrace } from "./api.js";
38
+ import { metricCounter, metricHistogram, timed } from "./metrics.js";
39
+ import { getLogger } from "./record.js";
40
+ import { traceHeaders, withAction, withApiTrace } from "./http.js";
41
+ export type { ObsConfig, RouteTemplate, TraceSeedFn } from "./config.js";
42
+ export { configure, obsConfig, resetConfigForTests } from "./config.js";
43
+ export type { ErrorInfo, Fields, LevelInput, LevelName, ObsRecord, Sink, } from "./types.js";
44
+ export { bind, currentSpanId, currentTraceId, formatTraceparent, isSampled, parseTraceparent, runWithTrace, runWithSpan, traceIdFromCorrelationId, } from "./context.js";
45
+ export { getLogger, setObsRecorderForTests, toErrorInfo, type Logger } from "./record.js";
46
+ export { logEvent, withSpan, withTrace } from "./api.js";
47
+ export { metricCounter, metricHistogram, timed } from "./metrics.js";
48
+ export { renderJson, renderPretty } from "./render.js";
49
+ export { configureSinks, makeFileSink, makeStdoutSink } from "./sinks.js";
50
+ export { isServerless, resetLoggingForTests, resolveFileTargets, setupLogging, type FileTarget, } from "./setup.js";
51
+ export { currentIds, makeOtelLogSink, otelEnabled, withOtelServerSpan, withOtelSpan } from "./otel.js";
52
+ export { resolveTraceSeed, traceHeaders, withAction, withApiTrace } from "./http.js";
53
+ /**
54
+ * Configure the library and hand back the surface an app actually uses.
55
+ *
56
+ * Calling this is equivalent to `configure()` followed by `setupLogging()`,
57
+ * and the returned object is a convenience: every function on it is also a
58
+ * named export of this module, bound to the same single configuration. Apps
59
+ * that prefer plain imports can call `configure()` and ignore the return.
60
+ *
61
+ * `setupLogging()` is idempotent, which matters because Next calls
62
+ * `register()` more than once across HMR and dev.
63
+ */
64
+ export declare function createObs(cfg: ObsConfig): {
65
+ getLogger: typeof getLogger;
66
+ logEvent: typeof logEvent;
67
+ withSpan: typeof withSpan;
68
+ withTrace: typeof withTrace;
69
+ withApiTrace: typeof withApiTrace;
70
+ withAction: typeof withAction;
71
+ traceHeaders: typeof traceHeaders;
72
+ runWithTrace: typeof runWithTrace;
73
+ currentTraceId: typeof currentTraceId;
74
+ currentSpanId: typeof currentSpanId;
75
+ bind: typeof bind;
76
+ metricCounter: typeof metricCounter;
77
+ metricHistogram: typeof metricHistogram;
78
+ timed: typeof timed;
79
+ };
package/dist/index.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @diveinto/obs - structured logging, tracing and metrics for Next.js and
3
+ * Node services.
4
+ *
5
+ * One record shape, written to stdout and to two rotating files, with W3C
6
+ * trace context threaded through it, and optional OpenTelemetry export that
7
+ * is off until you set an endpoint. It grew out of consolidating four
8
+ * hand-maintained copies of the same library across four services, each of
9
+ * which had drifted and grown something the others lacked; this is the union
10
+ * of the four, and every one of those differences is pinned by a test.
11
+ *
12
+ * WHAT IT IS NOT: this module is runtime-agnostic and must stay importable
13
+ * from the Edge runtime. The Node-only pieces live behind their own imports:
14
+ * the OTLP start path is `@diveinto/obs/start`, and the file sinks are only
15
+ * wired by `setupLogging()`, which an app calls from `instrumentation.ts` on
16
+ * the Node runtime. Do not add a static `node:*` import here.
17
+ *
18
+ * Usage, once, from the app's instrumentation.ts:
19
+ *
20
+ * import { createObs } from "@diveinto/obs";
21
+ * export const obs = createObs({
22
+ * service: "my-service",
23
+ * envPrefix: "MYSVC",
24
+ * });
25
+ *
26
+ * and everywhere else, plain named imports:
27
+ *
28
+ * import { getLogger, withApiTrace, traceHeaders } from "@diveinto/obs";
29
+ *
30
+ * The record shape is the contract and is frozen:
31
+ * ts level trace_id span_id parent_span_id logger event msg + context
32
+ * A sibling Python implementation writes the same shape, which is what lets
33
+ * one grep follow a trace across services in either language.
34
+ */
35
+ import { configure } from "./config.js";
36
+ import { bind, currentSpanId, currentTraceId, runWithTrace } from "./context.js";
37
+ import { logEvent, withSpan, withTrace } from "./api.js";
38
+ import { metricCounter, metricHistogram, timed } from "./metrics.js";
39
+ import { getLogger } from "./record.js";
40
+ import { setupLogging } from "./setup.js";
41
+ import { traceHeaders, withAction, withApiTrace } from "./http.js";
42
+ export { configure, obsConfig, resetConfigForTests } from "./config.js";
43
+ export { bind, currentSpanId, currentTraceId, formatTraceparent, isSampled, parseTraceparent, runWithTrace, runWithSpan, traceIdFromCorrelationId, } from "./context.js";
44
+ export { getLogger, setObsRecorderForTests, toErrorInfo } from "./record.js";
45
+ export { logEvent, withSpan, withTrace } from "./api.js";
46
+ export { metricCounter, metricHistogram, timed } from "./metrics.js";
47
+ export { renderJson, renderPretty } from "./render.js";
48
+ export { configureSinks, makeFileSink, makeStdoutSink } from "./sinks.js";
49
+ export { isServerless, resetLoggingForTests, resolveFileTargets, setupLogging, } from "./setup.js";
50
+ export { currentIds, makeOtelLogSink, otelEnabled, withOtelServerSpan, withOtelSpan } from "./otel.js";
51
+ export { resolveTraceSeed, traceHeaders, withAction, withApiTrace } from "./http.js";
52
+ /**
53
+ * Configure the library and hand back the surface an app actually uses.
54
+ *
55
+ * Calling this is equivalent to `configure()` followed by `setupLogging()`,
56
+ * and the returned object is a convenience: every function on it is also a
57
+ * named export of this module, bound to the same single configuration. Apps
58
+ * that prefer plain imports can call `configure()` and ignore the return.
59
+ *
60
+ * `setupLogging()` is idempotent, which matters because Next calls
61
+ * `register()` more than once across HMR and dev.
62
+ */
63
+ export function createObs(cfg) {
64
+ configure(cfg);
65
+ setupLogging();
66
+ return {
67
+ getLogger,
68
+ logEvent,
69
+ withSpan,
70
+ withTrace,
71
+ withApiTrace,
72
+ withAction,
73
+ traceHeaders,
74
+ runWithTrace,
75
+ currentTraceId,
76
+ currentSpanId,
77
+ bind,
78
+ metricCounter,
79
+ metricHistogram,
80
+ timed,
81
+ };
82
+ }
@@ -0,0 +1,26 @@
1
+ import { type Attributes } from "@opentelemetry/api";
2
+ /**
3
+ * Add to a monotonic counter (default +1). No-op when export is off. `attrs` must
4
+ * be low-cardinality (see the file header) - the type is OTel's Attributes, but
5
+ * the cardinality contract is on the caller, not the type system.
6
+ */
7
+ export declare function metricCounter(name: string, value?: number, attrs?: Attributes): void;
8
+ /**
9
+ * Record one value into a histogram (latency, size, ...). No-op when export is
10
+ * off. Pass `unit` (e.g. "ms") on the first call to label the instrument; same
11
+ * low-cardinality rule applies to `attrs`.
12
+ */
13
+ export declare function metricHistogram(name: string, value: number, attrs?: Attributes, unit?: string): void;
14
+ /**
15
+ * Time `fn`, recording its elapsed milliseconds into the `name` histogram (unit
16
+ * "ms") with an `outcome` attribute of "ok" or "error". The outcome is folded
17
+ * into the histogram's own attributes (not a separate counter) so a single
18
+ * instrument carries both the latency distribution and the success/failure split
19
+ * - which is the standard RED-style shape an alert can read directly.
20
+ *
21
+ * When export is off this is a thin passthrough: it still runs `fn` (so callers
22
+ * can wrap unconditionally) but records nothing and adds no measurable overhead.
23
+ * It never swallows the error - it re-throws after stamping outcome="error", so
24
+ * the caller's control flow is unchanged.
25
+ */
26
+ export declare function timed<T>(name: string, attrs: Attributes | undefined, fn: () => Promise<T> | T): Promise<T>;