@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/context.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|