@diveinto/obs 1.0.5 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/spans.js ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Spans in the files, export that can be seen, and context for work that outlives its request.
3
+ *
4
+ * Three rules shape this module:
5
+ *
6
+ * - Every span the service cares about is written to its own files when it ends, as one
7
+ * record (`event: span.end`) carrying the span's own ids, name, kind, status and duration.
8
+ * A reader that has only the files (a log tool on the machine, an operator over ssh) can then
9
+ * draw the same span tree an OTLP backend shows; without it, a span that logged nothing is
10
+ * invisible, and every child of it reads as an orphan.
11
+ * - Nothing personal or secret leaves the process. What is exported passes `exportable`; the
12
+ * files keep everything, because they stay on the machine.
13
+ * - Export is seen, not assumed. Counters track what was sent and what failed, a periodic
14
+ * `obs.export` record says so, and a failure is logged to the files at most once a minute,
15
+ * never over the exporter that is failing.
16
+ *
17
+ * The span tree's ids come from the tracer whether or not anything is exported (see
18
+ * otel-start.ts), so the files look the same with export on and off.
19
+ */
20
+ import { context as otelContext, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, TraceFlags, } from "@opentelemetry/api";
21
+ import { obsConfig } from "./config.js";
22
+ import { newSpanId, newTraceId, runWithTrace } from "./context.js";
23
+ import { emitWith } from "./record.js";
24
+ /** The instrumentation scope of the spans this library opens itself. */
25
+ export const LIBRARY_SCOPE = "@diveinto/obs";
26
+ // ---- what may leave the process ---------------------------------------------------------------
27
+ // Keys whose values identify a person, a machine on a network, or a credential, or carry a query
28
+ // string or a full URL (which can hold any of those). Exact OpenTelemetry semantic-convention keys
29
+ // first, then a rule for the rest by their last word.
30
+ const DENY_EXACT = new Set([
31
+ "url.full",
32
+ "url.query",
33
+ "url.path",
34
+ "http.url",
35
+ "http.target",
36
+ "http.path",
37
+ "client.address",
38
+ "client.port",
39
+ "network.peer.address",
40
+ "net.peer.ip",
41
+ "net.sock.peer.addr",
42
+ "http.client_ip",
43
+ "user_agent.original",
44
+ "http.user_agent",
45
+ "enduser.id",
46
+ "user.id",
47
+ "user.email",
48
+ "user.name",
49
+ ]);
50
+ // A user agent in any spelling (`user_agent`, `user-agent`, `useragent`, `http.user.agent`).
51
+ const DENY_LAST_WORD = /(^|[._-])(token|password|passwd|secret|authorization|cookie|kubeconfig|email|sub|subject|ip|referer|referrer|user[._-]?agent|query)$/i;
52
+ /**
53
+ * Whether an attribute or field may be exported: not a credential, an address, a user agent, a
54
+ * query, a full URL or a personal identifier, and not one the service named in `exportDenyKeys`.
55
+ */
56
+ export function exportable(key) {
57
+ if (DENY_EXACT.has(key) || DENY_LAST_WORD.test(key))
58
+ return false;
59
+ return !obsConfig().exportDenyKeys.includes(key);
60
+ }
61
+ function exportableAttributes(attrs) {
62
+ const out = {};
63
+ if (!attrs)
64
+ return out;
65
+ for (const [k, v] of Object.entries(attrs)) {
66
+ if (exportable(k))
67
+ out[k] = v;
68
+ }
69
+ return out;
70
+ }
71
+ /**
72
+ * The span as it may leave the process: the same span with every attribute `exportable` refuses
73
+ * removed, on the span, its events and its links. A plain object rather than a wrapper around the
74
+ * SDK's span, so nothing in the exporter reaches back into the original's private state.
75
+ */
76
+ function forExport(span) {
77
+ const sc = span.spanContext();
78
+ return {
79
+ name: span.name,
80
+ kind: span.kind,
81
+ spanContext: () => sc,
82
+ parentSpanContext: span.parentSpanContext,
83
+ parentSpanId: span.parentSpanId,
84
+ startTime: span.startTime,
85
+ endTime: span.endTime,
86
+ duration: span.duration,
87
+ status: span.status,
88
+ attributes: exportableAttributes(span.attributes),
89
+ links: span.links.map((l) => ({ ...l, attributes: exportableAttributes(l.attributes) })),
90
+ events: span.events.map((e) => ({ ...e, attributes: exportableAttributes(e.attributes) })),
91
+ resource: span.resource,
92
+ instrumentationScope: span.instrumentationScope,
93
+ instrumentationLibrary: span.instrumentationLibrary,
94
+ ended: span.ended,
95
+ droppedAttributesCount: span.droppedAttributesCount,
96
+ droppedEventsCount: span.droppedEventsCount,
97
+ droppedLinksCount: span.droppedLinksCount,
98
+ };
99
+ }
100
+ const counters = {
101
+ spansWritten: 0,
102
+ spansExported: 0,
103
+ spansFailed: 0,
104
+ logsExported: 0,
105
+ logsFailed: 0,
106
+ };
107
+ /** The counters as they stand, a copy. */
108
+ export function exportCounters() {
109
+ return { ...counters };
110
+ }
111
+ // The process's own trace, for the records this module writes outside any request: the heartbeat
112
+ // and an export failure. A record with no trace id at all reads as a component that lost its
113
+ // tracing, so they carry this one. Minted on first use, never at import: this module sits in an
114
+ // import cycle (records, context, the tracer), and must do nothing while it loads.
115
+ let processTrace;
116
+ function processIds() {
117
+ processTrace ??= { traceId: newTraceId(), spanId: newSpanId(), parentSpanId: "" };
118
+ return processTrace;
119
+ }
120
+ const FAILURE_EVERY_MS = 60_000;
121
+ let lastFailureAt = 0;
122
+ /**
123
+ * Note an export failure in the files, at most once a minute, and never over OTLP: a failure
124
+ * logged through the exporter that failed would only make another.
125
+ */
126
+ function noteFailure(signal, error) {
127
+ const now = Date.now();
128
+ if (now - lastFailureAt < FAILURE_EVERY_MS)
129
+ return;
130
+ lastFailureAt = now;
131
+ emitWith(processIds(), {}, "WARNING", "obs", "obs.export_failed", `exporting ${signal} failed; the files are complete and export carries on trying`, { "obs.local": true, signal, ...counters }, error);
132
+ }
133
+ let heartbeat;
134
+ /**
135
+ * Write an `obs.export` record now and then every `everyMs`: whether export is on, and the
136
+ * counters. A component configured to export that has exported nothing in an hour shows it here.
137
+ * The timer never holds the process open.
138
+ */
139
+ export function startExportHeartbeat(exportOn, everyMs = 60_000) {
140
+ const beat = () => emitWith(processIds(), {}, "INFO", "obs", "obs.export", exportOn
141
+ ? `export on: ${counters.spansExported} span(s) and ${counters.logsExported} log(s) sent, ${counters.spansFailed + counters.logsFailed} failed`
142
+ : `export off: ${counters.spansWritten} span(s) written to the files only`, { "obs.export": exportOn ? "on" : "off", ...counters });
143
+ if (heartbeat)
144
+ clearInterval(heartbeat);
145
+ // the first a tick later: startOtel runs before a service's createObs in the usual order, and a
146
+ // record written before configure() can only reach stdout
147
+ setTimeout(beat, 0).unref?.();
148
+ if (!exportOn)
149
+ return;
150
+ heartbeat = setInterval(beat, everyMs);
151
+ heartbeat.unref?.();
152
+ }
153
+ /** Test seam: stop the heartbeat and zero the counters. */
154
+ export function resetExportForTests() {
155
+ if (heartbeat)
156
+ clearInterval(heartbeat);
157
+ heartbeat = undefined;
158
+ lastFailureAt = 0;
159
+ for (const k of Object.keys(counters))
160
+ counters[k] = 0;
161
+ }
162
+ /**
163
+ * A span exporter that sends only what may leave the process (`exportable`), counts what it sent
164
+ * and what failed, and notes a failure in the files. Never throws into the SDK.
165
+ */
166
+ export class GuardedSpanExporter {
167
+ inner;
168
+ constructor(inner) {
169
+ this.inner = inner;
170
+ }
171
+ export(spans, done) {
172
+ const settle = (r) => {
173
+ if (r.code === 0)
174
+ counters.spansExported += spans.length;
175
+ else {
176
+ counters.spansFailed += spans.length;
177
+ noteFailure("spans", r.error);
178
+ }
179
+ done(r);
180
+ };
181
+ try {
182
+ this.inner.export(spans.map(forExport), settle);
183
+ }
184
+ catch (e) {
185
+ settle({ code: 1, error: e });
186
+ }
187
+ }
188
+ shutdown() {
189
+ return this.inner.shutdown();
190
+ }
191
+ forceFlush() {
192
+ return this.inner.forceFlush?.() ?? Promise.resolve();
193
+ }
194
+ }
195
+ /** A log exporter that counts what it sent and what failed, as GuardedSpanExporter does. */
196
+ export class GuardedLogExporter {
197
+ inner;
198
+ constructor(inner) {
199
+ this.inner = inner;
200
+ }
201
+ export(records, done) {
202
+ const settle = (r) => {
203
+ if (r.code === 0)
204
+ counters.logsExported += records.length;
205
+ else {
206
+ counters.logsFailed += records.length;
207
+ noteFailure("logs", r.error);
208
+ }
209
+ done(r);
210
+ };
211
+ try {
212
+ this.inner.export(records, settle);
213
+ }
214
+ catch (e) {
215
+ settle({ code: 1, error: e });
216
+ }
217
+ }
218
+ shutdown() {
219
+ return this.inner.shutdown();
220
+ }
221
+ forceFlush() {
222
+ return this.inner.forceFlush?.() ?? Promise.resolve();
223
+ }
224
+ }
225
+ // ---- spans into the files ---------------------------------------------------------------------
226
+ const KIND_NAMES = {
227
+ [SpanKind.INTERNAL]: "internal",
228
+ [SpanKind.SERVER]: "server",
229
+ [SpanKind.CLIENT]: "client",
230
+ [SpanKind.PRODUCER]: "producer",
231
+ [SpanKind.CONSUMER]: "consumer",
232
+ };
233
+ const STATUS_NAMES = {
234
+ [SpanStatusCode.UNSET]: "unset",
235
+ [SpanStatusCode.OK]: "ok",
236
+ [SpanStatusCode.ERROR]: "error",
237
+ };
238
+ /** The most attributes a span.end record carries, so one span never makes a huge line. */
239
+ const RECORD_ATTRIBUTES = 20;
240
+ function hrMillis(t) {
241
+ return t[0] * 1000 + t[1] / 1e6;
242
+ }
243
+ /** A W3C traceparent for a span context, always marked sampled: every trace is kept. */
244
+ export function traceparentOf(sc) {
245
+ return `00-${sc.traceId}-${sc.spanId}-01`;
246
+ }
247
+ function scopeOf(span) {
248
+ return span.instrumentationScope?.name ?? span.instrumentationLibrary?.name ?? "";
249
+ }
250
+ /**
251
+ * Whether a span is written to the files: every span at a process boundary (server, client,
252
+ * producer, consumer), and the internal spans this service and this library open. A framework's
253
+ * own internal spans (a page render, a module resolution) stay in the backend: they are many, and
254
+ * not the story of the request.
255
+ */
256
+ function writtenToFile(span) {
257
+ if (span.kind !== SpanKind.INTERNAL)
258
+ return true;
259
+ const scope = scopeOf(span);
260
+ return scope === obsConfig().service || scope === LIBRARY_SCOPE;
261
+ }
262
+ /**
263
+ * A span processor that writes one `span.end` record per span it keeps (see `writtenToFile`), with
264
+ * the span's own trace, span and parent ids, so the record sits in the tree where the span does.
265
+ * The record's fields: `span.name`, `span.kind`, `span.status`, `dur_ms`, `span.scope`, the span's
266
+ * links as traceparents (`span.links`), an error's type and message, and up to twenty of its
267
+ * attributes. Errors are ERROR records, so a level filter finds a failed span.
268
+ */
269
+ export class FileSpanProcessor {
270
+ onStart() { }
271
+ onEnd(span) {
272
+ try {
273
+ if (!writtenToFile(span))
274
+ return;
275
+ const sc = span.spanContext();
276
+ const parent = span.parentSpanContext?.spanId ?? span.parentSpanId ?? "";
277
+ const ms = Math.round(hrMillis(span.duration) * 10) / 10;
278
+ const status = STATUS_NAMES[span.status.code] ?? "unset";
279
+ const fields = {
280
+ "span.name": span.name,
281
+ "span.kind": KIND_NAMES[span.kind] ?? "internal",
282
+ "span.status": status,
283
+ dur_ms: ms,
284
+ "span.scope": scopeOf(span),
285
+ };
286
+ if (span.links.length > 0) {
287
+ fields["span.links"] = span.links.map((l) => traceparentOf(l.context)).join(",");
288
+ }
289
+ const exception = span.events.find((e) => e.name === "exception");
290
+ if (status === "error") {
291
+ fields["error.type"] = String(exception?.attributes?.["exception.type"] ?? "error");
292
+ fields["error.message"] = String(exception?.attributes?.["exception.message"] ?? span.status.message ?? "");
293
+ }
294
+ let n = 0;
295
+ for (const [k, v] of Object.entries(span.attributes)) {
296
+ if (n >= RECORD_ATTRIBUTES)
297
+ break;
298
+ if (k in fields || v === undefined || typeof v === "object")
299
+ continue;
300
+ fields[k] = v;
301
+ n++;
302
+ }
303
+ counters.spansWritten++;
304
+ emitWith({ traceId: sc.traceId, spanId: sc.spanId, parentSpanId: parent }, {}, status === "error" ? "ERROR" : "INFO", "span", "span.end", `${span.name} ${status} in ${ms} ms`, fields);
305
+ }
306
+ catch {
307
+ /* a span the processor cannot read is not the request's problem */
308
+ }
309
+ }
310
+ forceFlush() {
311
+ return Promise.resolve();
312
+ }
313
+ shutdown() {
314
+ return Promise.resolve();
315
+ }
316
+ }
317
+ // ---- context for work that outlives its request -----------------------------------------------
318
+ const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
319
+ /**
320
+ * The span context a W3C traceparent names, or null for one that is missing, malformed, too long,
321
+ * or all zeros. A stored or annotated traceparent is untrusted input: it never fails the work that
322
+ * reads it, it only fails to be a parent.
323
+ */
324
+ export function spanContextFromTraceparent(value) {
325
+ if (!value || value.length > 55)
326
+ return null;
327
+ const m = TRACEPARENT.exec(value.trim());
328
+ const traceId = m?.[1];
329
+ const spanId = m?.[2];
330
+ if (!traceId || !spanId || /^0+$/.test(traceId) || /^0+$/.test(spanId))
331
+ return null;
332
+ return { traceId, spanId, traceFlags: TraceFlags.SAMPLED, isRemote: true };
333
+ }
334
+ /** The traceparent of the span in force, to store on a row or an object for later work; "" outside one. */
335
+ export function currentTraceparent() {
336
+ const span = trace.getActiveSpan();
337
+ if (span?.isRecording())
338
+ return traceparentOf(span.spanContext());
339
+ return "";
340
+ }
341
+ const KINDS = {
342
+ internal: SpanKind.INTERNAL,
343
+ server: SpanKind.SERVER,
344
+ client: SpanKind.CLIENT,
345
+ producer: SpanKind.PRODUCER,
346
+ consumer: SpanKind.CONSUMER,
347
+ };
348
+ /**
349
+ * Run work that another process asked for, or that a request asked for and is done later, as a
350
+ * span of its own: a child of the stored or annotated `parent` when there is one, else a new trace;
351
+ * linked to every traceparent in `links` (the other requests a batch serves, the operation an
352
+ * object was made for). A parent that is missing or malformed starts a new trace and never fails
353
+ * the work. An error is recorded on the span and thrown on.
354
+ */
355
+ export async function continueFrom(parent, name, fn, opts) {
356
+ const sc = spanContextFromTraceparent(parent);
357
+ const ctx = sc ? trace.setSpanContext(ROOT_CONTEXT, sc) : ROOT_CONTEXT;
358
+ const links = [];
359
+ for (const l of opts?.links ?? []) {
360
+ const lc = spanContextFromTraceparent(l);
361
+ if (lc)
362
+ links.push({ context: lc });
363
+ }
364
+ const attributes = {};
365
+ for (const [k, v] of Object.entries(opts?.attributes ?? {})) {
366
+ if (v !== undefined && v !== null && typeof v !== "object")
367
+ attributes[k] = v;
368
+ }
369
+ const tracer = trace.getTracer(LIBRARY_SCOPE);
370
+ return otelContext.with(ctx, () => tracer.startActiveSpan(name, { kind: KINDS[opts?.kind ?? "internal"], links, attributes }, async (span) => {
371
+ // Without a tracer provider (a test, an edge runtime) the span does not record; the work
372
+ // still gets ids of its own, continuing the parent's trace.
373
+ const run = () => fn();
374
+ const body = span.isRecording()
375
+ ? run
376
+ : () => runWithTrace({ traceId: sc?.traceId, parentSpanId: sc?.spanId }, run);
377
+ try {
378
+ const out = await body();
379
+ span.end();
380
+ return out;
381
+ }
382
+ catch (e) {
383
+ span.recordException(e);
384
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e?.message });
385
+ span.end();
386
+ throw e;
387
+ }
388
+ }));
389
+ }
package/dist/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  // Severity vocabulary + numeric ordering. Matches the agent's stdlib levels
11
11
  // (DEBUG < INFO < WARNING < ERROR < CRITICAL) so a LOG_LEVEL floor means the
12
- // same thing on both sides of the fleet.
12
+ // same thing in either language.
13
13
  export const LEVELS = {
14
14
  DEBUG: 10,
15
15
  INFO: 20,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diveinto/obs",
3
- "version": "1.0.5",
3
+ "version": "1.1.1",
4
4
  "description": "Structured logging, tracing and metrics for Next.js and Node services: one record shape, rotating files, W3C trace context, optional OpenTelemetry export",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,11 +39,12 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@opentelemetry/api": "^1.9.1",
42
- "@opentelemetry/api-logs": "^0.219.0",
43
- "@opentelemetry/exporter-logs-otlp-proto": "^0.219.0",
42
+ "@opentelemetry/api-logs": ">=0.200.0 <0.300.0",
43
+ "@opentelemetry/exporter-logs-otlp-proto": ">=0.200.0 <0.300.0",
44
44
  "@opentelemetry/resources": "^2.0.0",
45
- "@opentelemetry/sdk-logs": "^0.219.0",
46
- "@vercel/otel": "^2.1.3"
45
+ "@opentelemetry/sdk-logs": ">=0.200.0 <0.300.0",
46
+ "@vercel/otel": "^2.1.3",
47
+ "@opentelemetry/sdk-trace-base": "^2.0.0"
47
48
  },
48
49
  "peerDependenciesMeta": {
49
50
  "@opentelemetry/exporter-logs-otlp-proto": {
@@ -57,6 +58,9 @@
57
58
  },
58
59
  "@opentelemetry/resources": {
59
60
  "optional": true
61
+ },
62
+ "@opentelemetry/sdk-trace-base": {
63
+ "optional": true
60
64
  }
61
65
  },
62
66
  "devDependencies": {
@@ -68,7 +72,8 @@
68
72
  "@types/node": "^22.10.2",
69
73
  "@vercel/otel": "^2.1.3",
70
74
  "typescript": "^5.7.2",
71
- "vitest": "^3.0.5"
75
+ "vitest": "^3.0.5",
76
+ "@opentelemetry/sdk-trace-base": "^2.11.0"
72
77
  },
73
78
  "allowScripts": {
74
79
  "esbuild@0.28.2": true