@blamejs/core 0.7.102 → 0.7.103
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/CHANGELOG.md +2 -0
- package/lib/guard-mime.js +3 -2
- package/lib/middleware/headers.js +3 -2
- package/lib/middleware/index.js +8 -2
- package/lib/middleware/span-http-server.js +243 -0
- package/lib/middleware/trace-log-correlation.js +134 -0
- package/lib/middleware/trace-propagate.js +9 -0
- package/lib/observability-otlp-exporter.js +351 -0
- package/lib/observability-tracer.js +395 -0
- package/lib/observability.js +230 -6
- package/lib/safe-buffer.js +15 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.103** (2026-05-06) — W3C distributed tracing suite. End-to-end OTel-shaped tracing without a vendored OTel SDK: tracestate + Baggage parsers, span builder, OTLP/JSON exporter, HTTP-server span middleware, log correlation. **`b.observability.traceContext.parseTracestate / buildTracestate`** — W3C Trace Context §3.3 vendor data: enforces vendor-key shape (lcase-alnum + `_-*/`, optional `<tenant>@<system>`), value charset (printable ASCII excluding `,` and `=`), 32-entry cap, 512-char total cap, dup-key-keep-first per §3.3.1.5. **`b.observability.baggage.parse / build`** — W3C Baggage spec parser + builder for operator-supplied context (tenantId, region, experimentId, etc.) propagated across service boundaries. RFC 7230 tchar key grammar, percent-encoded UTF-8 values, optional per-entry properties (`key=value;property=value`), 64-entry / 8192-char caps. **`b.observability.tracer.create({ service, resource, onEnd })`** — OTel-shaped span builder. `tracer.start(name, opts)` returns a span with `setAttribute` / `setAttributes` / `addEvent` / `recordException` / `setStatus` / `end` / `isRecording` / `toJSON`. OTLP/JSON-compatible output (Trace v1) with `traceId` / `spanId` / `parentSpanId` / `name` / `kind` / `startTimeUnixNano` / `endTimeUnixNano` / `attributes` / `events` / `status` / `resource` / `scope` / `droppedAttributesCount` / `droppedEventsCount`. Attribute caps (128 keys, 1024-char values), event cap (128) per OTLP defaults. `tracer.startChildOf(parent, name)` derives child spans sharing the trace context. **`b.observability.tracer.spanToTraceparent(span)`** — emits the canonical W3C `traceparent` for outbound propagation. **`b.observability.otlpExporter.create({ endpoint, ... })`** — buffered OTLP/HTTP JSON span exporter. Batches spans (default 200), flushes on size + interval (default 5s), retries 5xx + 408/429 with exponential backoff, drops oldest on queue overflow (default 4096). Custom `fetchImpl` opt for testing or non-default HTTP transports; `allowedProtocols` opt for cleartext dev collectors. **`b.middleware.tracePropagate`** extended to also read inbound `tracestate` and stamp `req.trace.tracestate` as the parsed entries array (or `[]` when missing); when `setResponseHeader: true`, echoes both `traceparent` and `tracestate` on the response. **`b.middleware.spanHttpServer({ tracer, ... })`** — auto-creates a root server span per HTTP request, populates OTel `SEMCONV.HTTP_*` / `URL_*` / `SERVER_*` / `CLIENT_*` attributes, attaches the span to `req.span`, ends on response close, fires `onEnd(span.toJSON())` for export. `ignorePaths` (string + RegExp) keeps healthz / static-asset routes out of span volume; `captureRequestHeaders` / `captureResponseHeaders` lift named headers into the span as `http.request.header.*` / `http.response.header.*` attributes. **`b.middleware.traceLogCorrelation({ logger })`** — wraps a `b.log` instance for the request lifetime so every `info()` / `warn()` / `error()` / etc. emission inside the handler auto-includes `trace_id` + `span_id` from the active context (via `req.trace` + `req.span`). Pass-through when no trace context present. Internal sweep: `safeBuffer.TRACE_ID_HEX_RE` / `SPAN_ID_HEX_RE` / `RFC7230_TCHAR_RE` extracted as shared regex constants; `guard-mime` / `middleware/headers` / `observability` consolidated against the new shared constants.
|
|
12
|
+
|
|
11
13
|
- **0.7.102** (2026-05-06) — `b.middleware.tracePropagate({ generateIfMissing, ... })` — middleware that consumes the inbound `traceparent` header per W3C Trace Context and stamps `req.trace = { traceId, parentId, sampled, hadUpstream }` for downstream handlers + propagation into outbound HTTP calls. `generateIfMissing: true` (default) synthesises a fresh trace when the inbound header is missing/malformed and stamps `hadUpstream: false`. `auditOnMissing: true` emits `system.trace.synthesised` audit events on every locally-originated trace. `setResponseHeader: true` echoes the resolved traceparent on the response (useful when the framework is the back-end of an L7 router that wants to log it). Composes with `b.observability.traceContext` (v0.7.101) for outbound propagation: downstream handlers read `req.trace.traceId` and pass it to `traceContext.build({ traceId: req.trace.traceId, parentId: traceContext.newParentId(), sampled: req.trace.sampled })` for the `traceparent` header on upstream calls.
|
|
12
14
|
|
|
13
15
|
- **0.7.101** (2026-05-06) — `b.observability.traceContext` — W3C Trace Context (https://www.w3.org/TR/trace-context-1/) parser + builder. **`traceContext.parse(headerValue)`** consumes a `traceparent` HTTP header value (`00-<32hex traceId>-<16hex parentId>-<2hex flags>`) and returns `{ version, traceId, parentId, flags, sampled }` or null on malformed input. Enforces the §3.2.2.3 / §3.2.2.4 all-zero-rejection rule (zero trace-id and zero parent-id are explicitly forbidden by spec). **`traceContext.build({ traceId, parentId, sampled })`** produces a v1 `traceparent` header value; throws on bad input shape. **`traceContext.newTraceId()` / `newParentId()`** generate fresh randomized 128-bit / 64-bit hex strings (with the all-zero retry path the spec requires). Operators wiring distributed tracing across services use these to propagate trace IDs across an outbound HTTP call without a vendored OTel SDK — pair with the SEMCONV constants from v0.7.95 to build OTel-aligned spans on top.
|
package/lib/guard-mime.js
CHANGED
|
@@ -36,6 +36,7 @@ var lazyRequire = require("./lazy-require");
|
|
|
36
36
|
var gateContract = require("./gate-contract");
|
|
37
37
|
var C = require("./constants");
|
|
38
38
|
var numericBounds = require("./numeric-bounds");
|
|
39
|
+
var safeBuffer = require("./safe-buffer");
|
|
39
40
|
var { GuardMimeError } = require("./framework-error");
|
|
40
41
|
|
|
41
42
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
@@ -48,8 +49,8 @@ var _err = GuardMimeError.factory;
|
|
|
48
49
|
// 127 octets per token.
|
|
49
50
|
var TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&\-^_.+]{0,126}$/;
|
|
50
51
|
|
|
51
|
-
// Parameter token (RFC 7231 §3.1.1.1): tchar set.
|
|
52
|
-
var PARAM_TOKEN_RE =
|
|
52
|
+
// Parameter token (RFC 7231 §3.1.1.1): tchar set per RFC 7230.
|
|
53
|
+
var PARAM_TOKEN_RE = safeBuffer.RFC7230_TCHAR_RE;
|
|
53
54
|
|
|
54
55
|
// Quoted-string body (between double quotes) per RFC 7230 §3.2.6.
|
|
55
56
|
var QUOTED_STRING_BODY_RE = /^[\t\x20-\x7e]*$/; // allow:raw-byte-literal — printable ASCII range
|
|
@@ -34,12 +34,13 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
var lazyRequire = require("../lazy-require");
|
|
37
|
+
var safeBuffer = require("../safe-buffer");
|
|
37
38
|
|
|
38
39
|
var observability = lazyRequire(function () { return require("../observability"); });
|
|
39
40
|
void observability;
|
|
40
41
|
|
|
41
|
-
// RFC 9110 §5.1 token grammar — tchar set.
|
|
42
|
-
var TOKEN_RE =
|
|
42
|
+
// RFC 9110 §5.1 token grammar — tchar set per RFC 7230.
|
|
43
|
+
var TOKEN_RE = safeBuffer.RFC7230_TCHAR_RE;
|
|
43
44
|
|
|
44
45
|
var DEPRECATED_TRUST_HEADERS = Object.freeze([
|
|
45
46
|
"x-forwarded-for",
|
package/lib/middleware/index.js
CHANGED
|
@@ -45,7 +45,9 @@ var requireContentType = require("./require-content-type");
|
|
|
45
45
|
var requireMethods = require("./require-methods");
|
|
46
46
|
var securityHeaders = require("./security-headers");
|
|
47
47
|
var securityTxt = require("./security-txt");
|
|
48
|
+
var spanHttpServer = require("./span-http-server");
|
|
48
49
|
var sse = require("./sse");
|
|
50
|
+
var traceLogCorrelation = require("./trace-log-correlation");
|
|
49
51
|
var tracePropagate = require("./trace-propagate");
|
|
50
52
|
var tusUpload = require("./tus-upload");
|
|
51
53
|
var webAppManifest = require("./web-app-manifest");
|
|
@@ -81,7 +83,9 @@ module.exports = {
|
|
|
81
83
|
dpop: dpop.create,
|
|
82
84
|
hostAllowlist: hostAllowlist.create,
|
|
83
85
|
networkAllowlist: networkAllowlist.create,
|
|
84
|
-
|
|
86
|
+
spanHttpServer: spanHttpServer.create,
|
|
87
|
+
traceLogCorrelation: traceLogCorrelation.create,
|
|
88
|
+
tracePropagate: tracePropagate.create,
|
|
85
89
|
tusUpload: tusUpload.create,
|
|
86
90
|
webAppManifest: webAppManifest.create,
|
|
87
91
|
|
|
@@ -114,7 +118,9 @@ module.exports = {
|
|
|
114
118
|
dpop: dpop,
|
|
115
119
|
hostAllowlist: hostAllowlist,
|
|
116
120
|
networkAllowlist: networkAllowlist,
|
|
117
|
-
|
|
121
|
+
spanHttpServer: spanHttpServer,
|
|
122
|
+
traceLogCorrelation: traceLogCorrelation,
|
|
123
|
+
tracePropagate: tracePropagate,
|
|
118
124
|
tusUpload: tusUpload,
|
|
119
125
|
webAppManifest: webAppManifest,
|
|
120
126
|
},
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* spanHttpServer middleware — auto-creates a root span per HTTP
|
|
4
|
+
* request, populates OTel SEMCONV.HTTP_* attributes, attaches
|
|
5
|
+
* the span to req.span, and ends the span on response close.
|
|
6
|
+
*
|
|
7
|
+
* var tracer = b.observability.tracer.create({ service: "checkout" });
|
|
8
|
+
* var exporter = b.observability.otlpExporter.create({
|
|
9
|
+
* endpoint: "https://collector.example.com/v1/traces",
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* router.use(b.middleware.tracePropagate()); // populates req.trace
|
|
13
|
+
* router.use(b.middleware.spanHttpServer({
|
|
14
|
+
* tracer: tracer,
|
|
15
|
+
* onEnd: exporter.queue,
|
|
16
|
+
* ignorePaths: ["/healthz", /^\/static/],
|
|
17
|
+
* captureRequestHeaders: ["user-agent", "x-tenant-id"],
|
|
18
|
+
* captureResponseHeaders: ["content-type", "content-length"],
|
|
19
|
+
* }));
|
|
20
|
+
*
|
|
21
|
+
* app.get("/checkout", function (req, res) {
|
|
22
|
+
* // req.span is the active root server span
|
|
23
|
+
* req.span.setAttribute("checkout.cart_size", cart.items.length);
|
|
24
|
+
* var childSpan = tracer.startChildOf(req.span, "db.query");
|
|
25
|
+
* // ... do query work
|
|
26
|
+
* childSpan.end();
|
|
27
|
+
* res.json({ ok: true });
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* Span attributes auto-populated per OTel HTTP-server semconv:
|
|
31
|
+
* - http.request.method, http.route (when available)
|
|
32
|
+
* - url.scheme, url.path, url.query
|
|
33
|
+
* - server.address, client.address
|
|
34
|
+
* - user_agent.original
|
|
35
|
+
* - http.response.status_code (set when response writeHead fires)
|
|
36
|
+
*
|
|
37
|
+
* Span kind: "server".
|
|
38
|
+
*
|
|
39
|
+
* Skip paths: opts.ignorePaths accepts an array of strings (exact match)
|
|
40
|
+
* or RegExp instances. Use this to keep healthz / static-asset routes
|
|
41
|
+
* out of the span volume.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
var lazyRequire = require("../lazy-require");
|
|
45
|
+
var requestHelpers = require("../request-helpers");
|
|
46
|
+
var validateOpts = require("../validate-opts");
|
|
47
|
+
var { defineClass } = require("../framework-error");
|
|
48
|
+
|
|
49
|
+
var SpanHttpError = defineClass("SpanHttpError", { alwaysPermanent: true });
|
|
50
|
+
|
|
51
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
52
|
+
|
|
53
|
+
function _shouldIgnore(path, ignorePaths) {
|
|
54
|
+
if (!ignorePaths || !Array.isArray(ignorePaths)) return false;
|
|
55
|
+
for (var i = 0; i < ignorePaths.length; i++) {
|
|
56
|
+
var rule = ignorePaths[i];
|
|
57
|
+
if (typeof rule === "string" && rule === path) return true;
|
|
58
|
+
if (rule instanceof RegExp && rule.test(path)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _splitUrl(url) {
|
|
64
|
+
if (typeof url !== "string" || url.length === 0) return { path: "/", query: null };
|
|
65
|
+
var qIdx = url.indexOf("?");
|
|
66
|
+
if (qIdx === -1) return { path: url, query: null };
|
|
67
|
+
return { path: url.slice(0, qIdx), query: url.slice(qIdx + 1) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _scheme(req) {
|
|
71
|
+
var x = req.headers && (req.headers["x-forwarded-proto"] || "");
|
|
72
|
+
if (typeof x === "string" && x.length > 0) {
|
|
73
|
+
var first = x.split(",")[0].trim().toLowerCase();
|
|
74
|
+
if (first === "http" || first === "https") return first;
|
|
75
|
+
}
|
|
76
|
+
return (req.socket && req.socket.encrypted) ? "https" : "http";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _serverAddress(req) {
|
|
80
|
+
var hostHeader = req.headers && (req.headers["x-forwarded-host"] || req.headers.host);
|
|
81
|
+
if (typeof hostHeader === "string" && hostHeader.length > 0) {
|
|
82
|
+
return hostHeader.split(",")[0].trim();
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _captureHeaderAttrs(req, captureList, prefix) {
|
|
88
|
+
if (!Array.isArray(captureList) || captureList.length === 0) return {};
|
|
89
|
+
var out = Object.create(null);
|
|
90
|
+
for (var i = 0; i < captureList.length; i++) {
|
|
91
|
+
var name = String(captureList[i] || "").toLowerCase();
|
|
92
|
+
if (!name) continue;
|
|
93
|
+
var v = req.headers && req.headers[name];
|
|
94
|
+
if (v === undefined) continue;
|
|
95
|
+
if (Array.isArray(v)) v = v.join(", ");
|
|
96
|
+
out[prefix + "." + name] = String(v);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _captureResponseHeaderAttrs(res, captureList, prefix) {
|
|
102
|
+
if (!Array.isArray(captureList) || captureList.length === 0) return {};
|
|
103
|
+
var out = Object.create(null);
|
|
104
|
+
for (var i = 0; i < captureList.length; i++) {
|
|
105
|
+
var name = String(captureList[i] || "").toLowerCase();
|
|
106
|
+
if (!name) continue;
|
|
107
|
+
var v;
|
|
108
|
+
try { v = res.getHeader(name); } catch (_e) { continue; }
|
|
109
|
+
if (v === undefined || v === null) continue;
|
|
110
|
+
if (Array.isArray(v)) v = v.join(", ");
|
|
111
|
+
out[prefix + "." + name] = String(v);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function create(opts) {
|
|
117
|
+
validateOpts.requireObject(opts, "middleware.spanHttpServer", SpanHttpError);
|
|
118
|
+
validateOpts(opts, [
|
|
119
|
+
"tracer", "onEnd", "ignorePaths",
|
|
120
|
+
"captureRequestHeaders", "captureResponseHeaders",
|
|
121
|
+
"spanNameFn", "audit",
|
|
122
|
+
], "middleware.spanHttpServer");
|
|
123
|
+
|
|
124
|
+
if (!opts.tracer || typeof opts.tracer.start !== "function") {
|
|
125
|
+
throw new SpanHttpError("span-http/bad-tracer",
|
|
126
|
+
"middleware.spanHttpServer: tracer must be a b.observability.tracer.create() instance");
|
|
127
|
+
}
|
|
128
|
+
validateOpts.optionalFunction(opts.onEnd,
|
|
129
|
+
"middleware.spanHttpServer: onEnd", SpanHttpError, "span-http/bad-opts");
|
|
130
|
+
validateOpts.optionalFunction(opts.spanNameFn,
|
|
131
|
+
"middleware.spanHttpServer: spanNameFn", SpanHttpError, "span-http/bad-opts");
|
|
132
|
+
|
|
133
|
+
var tracer = opts.tracer;
|
|
134
|
+
var onEnd = opts.onEnd || null;
|
|
135
|
+
var ignorePaths = opts.ignorePaths || null;
|
|
136
|
+
var captureReqHeaders = opts.captureRequestHeaders || null;
|
|
137
|
+
var captureResHeaders = opts.captureResponseHeaders || null;
|
|
138
|
+
var spanNameFn = opts.spanNameFn || null;
|
|
139
|
+
var auditOn = opts.audit !== false;
|
|
140
|
+
|
|
141
|
+
return function spanHttpServerMiddleware(req, res, next) {
|
|
142
|
+
var SEMCONV = observability().SEMCONV;
|
|
143
|
+
var url = _splitUrl(req.url || "/");
|
|
144
|
+
if (_shouldIgnore(url.path, ignorePaths)) return next();
|
|
145
|
+
|
|
146
|
+
var spanName;
|
|
147
|
+
if (typeof spanNameFn === "function") {
|
|
148
|
+
try { spanName = String(spanNameFn(req)); }
|
|
149
|
+
catch (_e) { spanName = "http.server.request"; }
|
|
150
|
+
} else {
|
|
151
|
+
spanName = (req.method ? req.method.toUpperCase() + " " : "") + (url.path || "/");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
var traceId = req.trace && req.trace.traceId;
|
|
155
|
+
var parentId = req.trace && req.trace.parentId;
|
|
156
|
+
var sampled = !req.trace || req.trace.sampled !== false;
|
|
157
|
+
|
|
158
|
+
var span = tracer.start(spanName, {
|
|
159
|
+
traceId: traceId,
|
|
160
|
+
parentId: parentId,
|
|
161
|
+
sampled: sampled,
|
|
162
|
+
kind: "server",
|
|
163
|
+
attributes: Object.assign({},
|
|
164
|
+
{
|
|
165
|
+
[SEMCONV.HTTP_REQUEST_METHOD]: (req.method || "").toUpperCase(),
|
|
166
|
+
[SEMCONV.URL_SCHEME]: _scheme(req),
|
|
167
|
+
[SEMCONV.URL_PATH]: url.path,
|
|
168
|
+
},
|
|
169
|
+
url.query !== null ? { [SEMCONV.URL_QUERY]: url.query } : {},
|
|
170
|
+
(function () {
|
|
171
|
+
var serverAddr = _serverAddress(req);
|
|
172
|
+
return serverAddr ? { [SEMCONV.SERVER_ADDRESS]: serverAddr } : {};
|
|
173
|
+
})(),
|
|
174
|
+
(function () {
|
|
175
|
+
var clientAddr = requestHelpers.clientIp(req);
|
|
176
|
+
return clientAddr ? { [SEMCONV.CLIENT_ADDRESS]: clientAddr } : {};
|
|
177
|
+
})(),
|
|
178
|
+
(function () {
|
|
179
|
+
var ua = req.headers && req.headers["user-agent"];
|
|
180
|
+
return ua ? { [SEMCONV.USER_AGENT_ORIGINAL]: String(ua) } : {};
|
|
181
|
+
})(),
|
|
182
|
+
_captureHeaderAttrs(req, captureReqHeaders, "http.request.header")),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
req.span = span;
|
|
186
|
+
|
|
187
|
+
var ended = false;
|
|
188
|
+
function _finish(err) {
|
|
189
|
+
if (ended) return;
|
|
190
|
+
ended = true;
|
|
191
|
+
try {
|
|
192
|
+
var status = res.statusCode;
|
|
193
|
+
if (typeof status === "number") {
|
|
194
|
+
span.setAttribute(SEMCONV.HTTP_RESPONSE_STATUS_CODE, status);
|
|
195
|
+
if (status >= 500) {
|
|
196
|
+
span.setStatus("error", "HTTP " + status);
|
|
197
|
+
} else if (status >= 400) {
|
|
198
|
+
// Per OTel semconv: client errors don't auto-set error status
|
|
199
|
+
// (they're "expected" failures). Operators that want to flag
|
|
200
|
+
// 4xx as errors call span.setStatus("error", ...) themselves.
|
|
201
|
+
span.setStatus("ok");
|
|
202
|
+
} else {
|
|
203
|
+
span.setStatus("ok");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (err) span.recordException(err);
|
|
207
|
+
var resHeaders = _captureResponseHeaderAttrs(res, captureResHeaders, "http.response.header");
|
|
208
|
+
var resHeaderKeys = Object.keys(resHeaders);
|
|
209
|
+
for (var i = 0; i < resHeaderKeys.length; i++) {
|
|
210
|
+
span.setAttribute(resHeaderKeys[i], resHeaders[resHeaderKeys[i]]);
|
|
211
|
+
}
|
|
212
|
+
if (req.route && req.route.path) {
|
|
213
|
+
span.setAttribute(SEMCONV.HTTP_ROUTE, String(req.route.path));
|
|
214
|
+
}
|
|
215
|
+
} catch (_e) { /* drop-silent — observability sink */ }
|
|
216
|
+
try { span.end(); }
|
|
217
|
+
catch (_e) { /* drop-silent */ }
|
|
218
|
+
if (typeof onEnd === "function") {
|
|
219
|
+
try { onEnd(span.toJSON()); }
|
|
220
|
+
catch (_e) { /* operator hook — drop-silent */ }
|
|
221
|
+
}
|
|
222
|
+
if (auditOn) {
|
|
223
|
+
try {
|
|
224
|
+
observability().safeEvent("middleware.spanHttpServer.complete", 1, {
|
|
225
|
+
kind: "server",
|
|
226
|
+
sampled: span.sampled ? "1" : "0",
|
|
227
|
+
});
|
|
228
|
+
} catch (_e) { /* drop-silent */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
res.on("finish", function () { _finish(null); });
|
|
233
|
+
res.on("close", function () { _finish(null); });
|
|
234
|
+
res.on("error", function (e) { _finish(e); });
|
|
235
|
+
|
|
236
|
+
return next();
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
create: create,
|
|
242
|
+
SpanHttpError: SpanHttpError,
|
|
243
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* trace-log-correlation middleware — wraps the operator's b.log
|
|
4
|
+
* instance for the request lifetime so every log() / info() / warn()
|
|
5
|
+
* / error() / debug() call inside the handler auto-includes the
|
|
6
|
+
* canonical trace_id + span_id (and tenant context from W3C Baggage
|
|
7
|
+
* when present).
|
|
8
|
+
*
|
|
9
|
+
* var log = b.log.boot("api");
|
|
10
|
+
* router.use(b.middleware.tracePropagate());
|
|
11
|
+
* router.use(b.middleware.traceLogCorrelation({
|
|
12
|
+
* logger: log,
|
|
13
|
+
* reqField: "log", // attaches as req.log
|
|
14
|
+
* }));
|
|
15
|
+
*
|
|
16
|
+
* app.get("/widgets", function (req, res) {
|
|
17
|
+
* // req.log is the wrapped logger; every emission carries
|
|
18
|
+
* // trace_id + span_id + (optional) baggage attributes
|
|
19
|
+
* req.log.info("loading widgets");
|
|
20
|
+
* // → { ..., trace_id: "abc...", span_id: "def...",
|
|
21
|
+
* // baggage: { tenant: "acme" } }
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* The wrapper is a thin adapter: it does not change log levels,
|
|
25
|
+
* sinks, or the b.log API surface. Logs pass through to the
|
|
26
|
+
* wrapped logger with the trace fields injected via the meta-object
|
|
27
|
+
* second argument.
|
|
28
|
+
*
|
|
29
|
+
* When no req.trace is present (unusual — operators typically mount
|
|
30
|
+
* tracePropagate first), the wrapper is a no-op pass-through; logs
|
|
31
|
+
* still flow but without correlation fields.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var lazyRequire = require("../lazy-require");
|
|
35
|
+
var validateOpts = require("../validate-opts");
|
|
36
|
+
var { defineClass } = require("../framework-error");
|
|
37
|
+
|
|
38
|
+
var TraceLogError = defineClass("TraceLogError", { alwaysPermanent: true });
|
|
39
|
+
|
|
40
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
41
|
+
|
|
42
|
+
var LOG_LEVELS = ["debug", "info", "warn", "error", "fatal"];
|
|
43
|
+
|
|
44
|
+
function _baggageToObject(entries) {
|
|
45
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
46
|
+
var out = Object.create(null);
|
|
47
|
+
for (var i = 0; i < entries.length; i++) {
|
|
48
|
+
out[entries[i].key] = entries[i].value;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _wrapLogger(baseLogger, req, opts) {
|
|
54
|
+
if (!baseLogger || typeof baseLogger !== "object") return baseLogger;
|
|
55
|
+
var wrapped = Object.create(null);
|
|
56
|
+
// Preserve any non-level properties the operator put on the
|
|
57
|
+
// logger (e.g. boot context, child-logger metadata).
|
|
58
|
+
var keys = Object.keys(baseLogger);
|
|
59
|
+
for (var i = 0; i < keys.length; i++) {
|
|
60
|
+
if (LOG_LEVELS.indexOf(keys[i]) === -1) wrapped[keys[i]] = baseLogger[keys[i]];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _enrichMeta(meta) {
|
|
64
|
+
var enriched = Object.assign({}, meta || {});
|
|
65
|
+
if (req && req.trace) {
|
|
66
|
+
enriched.trace_id = req.trace.traceId;
|
|
67
|
+
// span_id prefers the active span (set by spanHttpServer) over
|
|
68
|
+
// the trace context's parentId
|
|
69
|
+
if (req.span && typeof req.span.spanId === "string") {
|
|
70
|
+
enriched.span_id = req.span.spanId;
|
|
71
|
+
} else if (typeof req.trace.parentId === "string") {
|
|
72
|
+
enriched.span_id = req.trace.parentId;
|
|
73
|
+
}
|
|
74
|
+
if (opts.includeBaggage !== false) {
|
|
75
|
+
var bg = _baggageToObject(req.trace.tracestate);
|
|
76
|
+
// tracestate is vendor-trace data; baggage is operator data.
|
|
77
|
+
// Operators usually want baggage in logs, not tracestate.
|
|
78
|
+
// We don't have a separate req.baggage today; keep this as
|
|
79
|
+
// the path for when tracePropagate exposes it. For now,
|
|
80
|
+
// emit the resolved tracestate shape under "trace_state".
|
|
81
|
+
if (bg) enriched.trace_state = bg;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return enriched;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bind each level on the underlying logger so it emits with the
|
|
88
|
+
// enriched meta. We don't replace the underlying logger's bound
|
|
89
|
+
// emitter shape — it still receives meta as the second argument.
|
|
90
|
+
for (var li = 0; li < LOG_LEVELS.length; li++) {
|
|
91
|
+
(function (lvl) {
|
|
92
|
+
if (typeof baseLogger[lvl] !== "function") return;
|
|
93
|
+
wrapped[lvl] = function (msg, meta) {
|
|
94
|
+
try { return baseLogger[lvl](msg, _enrichMeta(meta)); }
|
|
95
|
+
catch (_e) { /* drop-silent — log sink */ }
|
|
96
|
+
};
|
|
97
|
+
})(LOG_LEVELS[li]);
|
|
98
|
+
}
|
|
99
|
+
// Pass through anything else the logger might expose (boot, child, etc.)
|
|
100
|
+
if (typeof baseLogger.boot === "function") wrapped.boot = baseLogger.boot.bind(baseLogger);
|
|
101
|
+
if (typeof baseLogger.child === "function") wrapped.child = baseLogger.child.bind(baseLogger);
|
|
102
|
+
return wrapped;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function create(opts) {
|
|
106
|
+
validateOpts.requireObject(opts, "middleware.traceLogCorrelation", TraceLogError);
|
|
107
|
+
validateOpts(opts, [
|
|
108
|
+
"logger", "reqField", "includeBaggage", "audit",
|
|
109
|
+
], "middleware.traceLogCorrelation");
|
|
110
|
+
|
|
111
|
+
if (!opts.logger || typeof opts.logger !== "object") {
|
|
112
|
+
throw new TraceLogError("trace-log/bad-logger",
|
|
113
|
+
"middleware.traceLogCorrelation: logger must be a b.log instance");
|
|
114
|
+
}
|
|
115
|
+
var reqField = opts.reqField || "log";
|
|
116
|
+
if (typeof reqField !== "string" || reqField.length === 0) {
|
|
117
|
+
throw new TraceLogError("trace-log/bad-reqfield",
|
|
118
|
+
"middleware.traceLogCorrelation: reqField must be a non-empty string");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return function traceLogCorrelationMiddleware(req, res, next) {
|
|
122
|
+
req[reqField] = _wrapLogger(opts.logger, req, opts);
|
|
123
|
+
void observability; // touch lazyRequire so the dep is captured
|
|
124
|
+
return next();
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
create: create,
|
|
130
|
+
TraceLogError: TraceLogError,
|
|
131
|
+
// exported for tests
|
|
132
|
+
_wrapLogger: _wrapLogger,
|
|
133
|
+
LOG_LEVELS: LOG_LEVELS,
|
|
134
|
+
};
|
|
@@ -61,12 +61,17 @@ function create(opts) {
|
|
|
61
61
|
var tc = observability().traceContext;
|
|
62
62
|
var inbound = req.headers && req.headers.traceparent;
|
|
63
63
|
var parsed = (typeof inbound === "string") ? tc.parse(inbound) : null;
|
|
64
|
+
var inboundTracestate = req.headers && req.headers.tracestate;
|
|
65
|
+
var tracestateEntries = (typeof inboundTracestate === "string")
|
|
66
|
+
? tc.parseTracestate(inboundTracestate)
|
|
67
|
+
: null;
|
|
64
68
|
if (parsed) {
|
|
65
69
|
req.trace = {
|
|
66
70
|
traceId: parsed.traceId,
|
|
67
71
|
parentId: parsed.parentId,
|
|
68
72
|
sampled: parsed.sampled,
|
|
69
73
|
hadUpstream: true,
|
|
74
|
+
tracestate: tracestateEntries || [],
|
|
70
75
|
};
|
|
71
76
|
} else if (generateIfMissing) {
|
|
72
77
|
req.trace = {
|
|
@@ -74,6 +79,7 @@ function create(opts) {
|
|
|
74
79
|
parentId: tc.newParentId(),
|
|
75
80
|
sampled: true,
|
|
76
81
|
hadUpstream: false,
|
|
82
|
+
tracestate: [],
|
|
77
83
|
};
|
|
78
84
|
if (auditOnMissing && auditOn) {
|
|
79
85
|
try {
|
|
@@ -95,6 +101,9 @@ function create(opts) {
|
|
|
95
101
|
parentId: req.trace.parentId,
|
|
96
102
|
sampled: req.trace.sampled,
|
|
97
103
|
}));
|
|
104
|
+
if (req.trace.tracestate && req.trace.tracestate.length > 0) {
|
|
105
|
+
res.setHeader("tracestate", tc.buildTracestate(req.trace.tracestate));
|
|
106
|
+
}
|
|
98
107
|
} catch (_e) { /* drop-silent — header set best-effort */ }
|
|
99
108
|
}
|
|
100
109
|
return next();
|