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