@foam-ai/node 0.1.0-alpha.4 → 0.1.0-alpha.5
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/README.md +299 -183
- package/dist/constants.d.ts +9 -8
- package/dist/constants.js +49 -6
- package/dist/endpoint.d.ts +2 -0
- package/dist/endpoint.js +11 -0
- package/dist/exporters.js +4 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -1
- package/dist/ingest.js +15 -12
- package/dist/init.d.ts +3 -3
- package/dist/init.js +64 -35
- package/dist/instrumentations.js +25 -3
- package/dist/logs.d.ts +9 -0
- package/dist/logs.js +61 -0
- package/dist/network-capture/collector.js +96 -52
- package/dist/network-capture/http.d.ts +0 -1
- package/dist/network-capture/http.js +23 -25
- package/dist/network-capture/index.js +10 -0
- package/dist/network-capture/redact.d.ts +6 -0
- package/dist/network-capture/redact.js +87 -0
- package/dist/network-capture/undici.d.ts +0 -4
- package/dist/network-capture/undici.js +55 -22
- package/dist/otlp.d.ts +8 -0
- package/dist/otlp.js +46 -0
- package/dist/report.d.ts +9 -0
- package/dist/report.js +56 -0
- package/dist/state.d.ts +11 -4
- package/dist/state.js +26 -9
- package/dist/traces.d.ts +1 -0
- package/dist/traces.js +21 -0
- package/dist/utils.js +1 -1
- package/package.json +1 -1
- package/dist/diagnostics.d.ts +0 -13
- package/dist/diagnostics.js +0 -83
|
@@ -9,6 +9,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
9
9
|
const node_zlib_1 = require("node:zlib");
|
|
10
10
|
const constants_js_1 = require("../constants.js");
|
|
11
11
|
const utils_js_1 = require("../utils.js");
|
|
12
|
+
const redact_js_1 = require("./redact.js");
|
|
12
13
|
const ALLOWED_HEADERS = new Set(constants_js_1.SAFE_NETWORK_HEADERS);
|
|
13
14
|
const ATTR = {
|
|
14
15
|
request: {
|
|
@@ -48,12 +49,14 @@ function setHeaderAttributes(span, direction, headers) {
|
|
|
48
49
|
if (Object.keys(attributes).length > 0)
|
|
49
50
|
span.setAttributes(attributes);
|
|
50
51
|
}
|
|
52
|
+
function stringEncoding(encoding) {
|
|
53
|
+
return typeof encoding === "string" && Buffer.isEncoding(encoding)
|
|
54
|
+
? encoding
|
|
55
|
+
: "utf8";
|
|
56
|
+
}
|
|
51
57
|
function copyBody(value, encoding) {
|
|
52
58
|
if (typeof value === "string") {
|
|
53
|
-
|
|
54
|
-
? encoding
|
|
55
|
-
: "utf8";
|
|
56
|
-
return Buffer.from(value, enc);
|
|
59
|
+
return Buffer.from(value, stringEncoding(encoding));
|
|
57
60
|
}
|
|
58
61
|
if (Buffer.isBuffer(value))
|
|
59
62
|
return Buffer.from(value);
|
|
@@ -62,25 +65,39 @@ function copyBody(value, encoding) {
|
|
|
62
65
|
}
|
|
63
66
|
return undefined;
|
|
64
67
|
}
|
|
68
|
+
// Byte length of a body chunk without copying it; undefined for non-body values.
|
|
69
|
+
function bodyByteLength(value, encoding) {
|
|
70
|
+
if (typeof value === "string") {
|
|
71
|
+
return Buffer.byteLength(value, stringEncoding(encoding));
|
|
72
|
+
}
|
|
73
|
+
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) {
|
|
74
|
+
return value.byteLength;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
65
78
|
function decodeBody(bytes, contentEncoding) {
|
|
66
79
|
if (contentEncoding === undefined)
|
|
67
80
|
return bytes;
|
|
68
81
|
const encoding = headerValues(contentEncoding)[0]?.toLowerCase().trim();
|
|
69
82
|
if (!encoding || encoding === "identity")
|
|
70
83
|
return bytes;
|
|
84
|
+
// maxOutputLength turns a decompression bomb into a caught throw instead
|
|
85
|
+
// of an unrecoverable synchronous OOM allocation.
|
|
86
|
+
const limits = { maxOutputLength: constants_js_1.NETWORK_BODY_MAX_DECODE_BYTES };
|
|
71
87
|
try {
|
|
72
|
-
if (encoding === "gzip" || encoding === "x-gzip")
|
|
73
|
-
return (0, node_zlib_1.gunzipSync)(bytes);
|
|
88
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
89
|
+
return (0, node_zlib_1.gunzipSync)(bytes, limits);
|
|
90
|
+
}
|
|
74
91
|
if (encoding === "deflate") {
|
|
75
92
|
try {
|
|
76
|
-
return (0, node_zlib_1.inflateSync)(bytes);
|
|
93
|
+
return (0, node_zlib_1.inflateSync)(bytes, limits);
|
|
77
94
|
}
|
|
78
95
|
catch {
|
|
79
|
-
return (0, node_zlib_1.unzipSync)(bytes);
|
|
96
|
+
return (0, node_zlib_1.unzipSync)(bytes, limits);
|
|
80
97
|
}
|
|
81
98
|
}
|
|
82
99
|
if (encoding === "br")
|
|
83
|
-
return (0, node_zlib_1.brotliDecompressSync)(bytes);
|
|
100
|
+
return (0, node_zlib_1.brotliDecompressSync)(bytes, limits);
|
|
84
101
|
}
|
|
85
102
|
catch {
|
|
86
103
|
return undefined;
|
|
@@ -97,7 +114,7 @@ function textEncoding(contentType) {
|
|
|
97
114
|
.find((part) => part.toLowerCase().startsWith("charset="))
|
|
98
115
|
?.slice("charset=".length)
|
|
99
116
|
.toLowerCase();
|
|
100
|
-
const
|
|
117
|
+
const isText = media.startsWith("text/") ||
|
|
101
118
|
media.endsWith("/json") ||
|
|
102
119
|
media.endsWith("+json") ||
|
|
103
120
|
media.endsWith("/xml") ||
|
|
@@ -106,11 +123,13 @@ function textEncoding(contentType) {
|
|
|
106
123
|
media.endsWith("+yaml") ||
|
|
107
124
|
media === "application/x-www-form-urlencoded" ||
|
|
108
125
|
Boolean(charset);
|
|
109
|
-
if (!
|
|
126
|
+
if (!isText)
|
|
110
127
|
return undefined;
|
|
111
|
-
|
|
128
|
+
if (charset && Buffer.isEncoding(charset))
|
|
129
|
+
return charset;
|
|
130
|
+
return "utf8";
|
|
112
131
|
}
|
|
113
|
-
|
|
132
|
+
// Last index such that `buf.subarray(0, end)` is complete UTF-8, capped at `max`.
|
|
114
133
|
function utf8End(buf, max) {
|
|
115
134
|
const end = Math.min(max, buf.byteLength);
|
|
116
135
|
if (end === 0)
|
|
@@ -132,20 +151,17 @@ function utf8End(buf, max) {
|
|
|
132
151
|
: Number.POSITIVE_INFINITY;
|
|
133
152
|
return end - (leadAt - 1) >= needed ? end : leadAt - 1;
|
|
134
153
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
? utf8End(limited, limited.byteLength)
|
|
147
|
-
: limited.byteLength;
|
|
148
|
-
return limited.subarray(0, end).toString(encoding);
|
|
154
|
+
function cappedBody(buf) {
|
|
155
|
+
if (buf.byteLength <= constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES)
|
|
156
|
+
return buf;
|
|
157
|
+
return buf.subarray(0, constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES);
|
|
158
|
+
}
|
|
159
|
+
function decodeText(decoded, encoding) {
|
|
160
|
+
const limited = cappedBody(decoded);
|
|
161
|
+
if (encoding === "utf8" || encoding === "utf-8") {
|
|
162
|
+
return limited.subarray(0, utf8End(limited, limited.byteLength)).toString(encoding);
|
|
163
|
+
}
|
|
164
|
+
return limited.toString(encoding);
|
|
149
165
|
}
|
|
150
166
|
class BodyCollector {
|
|
151
167
|
span;
|
|
@@ -195,17 +211,28 @@ class BodyCollector {
|
|
|
195
211
|
observe(value, encoding) {
|
|
196
212
|
if (this.finalized)
|
|
197
213
|
return;
|
|
214
|
+
if (this.capturedBytes >= constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES) {
|
|
215
|
+
// Past the cap every chunk is discarded; only its length is needed
|
|
216
|
+
// (for the observed size), so skip the per-chunk copy that would
|
|
217
|
+
// otherwise double the allocation traffic of large transfers.
|
|
218
|
+
const length = bodyByteLength(value, encoding);
|
|
219
|
+
if (length === undefined)
|
|
220
|
+
return;
|
|
221
|
+
this.deadline.refresh();
|
|
222
|
+
this.observedBytes += length;
|
|
223
|
+
this.truncated = true;
|
|
224
|
+
this.span.setAttributes({
|
|
225
|
+
[this.attr.size]: this.observedBytes,
|
|
226
|
+
[this.attr.truncated]: true,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
198
230
|
const bytes = copyBody(value, encoding);
|
|
199
231
|
if (!bytes)
|
|
200
232
|
return;
|
|
201
233
|
this.deadline.refresh();
|
|
202
234
|
this.observedBytes += bytes.byteLength;
|
|
203
235
|
this.span.setAttribute(this.attr.size, this.observedBytes);
|
|
204
|
-
if (this.capturedBytes >= constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES) {
|
|
205
|
-
this.truncated = true;
|
|
206
|
-
this.span.setAttribute(this.attr.truncated, true);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
236
|
const room = constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES - this.capturedBytes;
|
|
210
237
|
const kept = bytes.byteLength > room ? bytes.subarray(0, room) : bytes;
|
|
211
238
|
this.chunks.push(kept);
|
|
@@ -236,35 +263,52 @@ class BodyCollector {
|
|
|
236
263
|
});
|
|
237
264
|
}
|
|
238
265
|
emit(buffers, headers, complete) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
266
|
+
let outcome;
|
|
267
|
+
if (this.observedBytes === 0)
|
|
268
|
+
outcome = "empty";
|
|
269
|
+
else if (complete)
|
|
270
|
+
outcome = "captured";
|
|
271
|
+
else
|
|
272
|
+
outcome = "incomplete";
|
|
244
273
|
if (outcome === "empty")
|
|
245
274
|
return;
|
|
246
|
-
|
|
275
|
+
let wire;
|
|
276
|
+
if (buffers.length === 0)
|
|
277
|
+
wire = undefined;
|
|
278
|
+
else if (buffers.length === 1)
|
|
279
|
+
wire = buffers[0];
|
|
280
|
+
else
|
|
281
|
+
wire = Buffer.concat(buffers);
|
|
282
|
+
// Span attributes cannot hold bytes (OTel AttributeValue). Text goes on
|
|
283
|
+
// the span; binary uses the same `http.*.body.content` name on the event.
|
|
247
284
|
let binaryContent;
|
|
248
|
-
|
|
285
|
+
let chunkSource = wire;
|
|
286
|
+
if (wire !== undefined) {
|
|
249
287
|
const decoded = decodeBody(wire, headers["content-encoding"]);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
? spanBodyContent(decoded, headers["content-type"])
|
|
253
|
-
: undefined;
|
|
254
|
-
if (text !== undefined) {
|
|
255
|
-
this.span.setAttribute(this.attr.content, text);
|
|
288
|
+
if (decoded === undefined) {
|
|
289
|
+
binaryContent = Uint8Array.from(cappedBody(wire));
|
|
256
290
|
}
|
|
257
291
|
else {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
292
|
+
const encoding = textEncoding(headers["content-type"]);
|
|
293
|
+
if (encoding !== undefined) {
|
|
294
|
+
const redacted = (0, redact_js_1.redactBodyText)(decodeText(decoded, encoding), headers["content-type"]);
|
|
295
|
+
this.span.setAttribute(this.attr.content, redacted.text);
|
|
296
|
+
if (redacted.changed) {
|
|
297
|
+
// pcga11: Chunk events must not re-ship bytes the span content
|
|
298
|
+
// redacted, so the (possibly compressed) wire bytes are replaced
|
|
299
|
+
// with the redacted text.
|
|
300
|
+
chunkSource = Buffer.from(redacted.text, encoding);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
binaryContent = Uint8Array.from(cappedBody(decoded));
|
|
305
|
+
}
|
|
262
306
|
}
|
|
263
307
|
}
|
|
264
308
|
const chunks = [];
|
|
265
|
-
if (
|
|
266
|
-
for (let i = 0; i <
|
|
267
|
-
chunks.push(
|
|
309
|
+
if (chunkSource) {
|
|
310
|
+
for (let i = 0; i < chunkSource.byteLength; i += constants_js_1.NETWORK_BODY_CHUNK_BYTES) {
|
|
311
|
+
chunks.push(chunkSource.subarray(i, i + constants_js_1.NETWORK_BODY_CHUNK_BYTES));
|
|
268
312
|
}
|
|
269
313
|
}
|
|
270
314
|
const common = {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { HttpRequestCustomAttributeFunction, HttpResponseCustomAttributeFunction } from "@opentelemetry/instrumentation-http";
|
|
2
|
-
/** Builds OTel HTTP request/response hooks that record headers as span attributes and stream bodies as log events. */
|
|
3
2
|
export declare function createNetworkCaptureHooks(bodyCaptureEnabled?: boolean): {
|
|
4
3
|
requestHook: HttpRequestCustomAttributeFunction;
|
|
5
4
|
responseHook: HttpResponseCustomAttributeFunction;
|
|
@@ -46,7 +46,7 @@ function observeDirectHeaders(args, headers) {
|
|
|
46
46
|
append(rawKey, value);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
// pcga11:Replaces a method with a wrapped version; returns an undo that restores it unless someone else re-patched it.
|
|
50
50
|
function swapMethod(target, key, wrap) {
|
|
51
51
|
const original = target[key];
|
|
52
52
|
const wrapped = wrap(original);
|
|
@@ -56,7 +56,7 @@ function swapMethod(target, key, wrap) {
|
|
|
56
56
|
target[key] = original;
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
// Wraps writeHead (when present) to record inline headers; returns an undo.
|
|
60
60
|
function swapWriteHead(target, directHeaders, afterWriteHead) {
|
|
61
61
|
const original = target.writeHead;
|
|
62
62
|
if (!original)
|
|
@@ -75,7 +75,7 @@ function swapWriteHead(target, directHeaders, afterWriteHead) {
|
|
|
75
75
|
target.writeHead = original;
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// Attaches one-shot listeners; returns an undo that removes all of them.
|
|
79
79
|
function listenOnce(target, listeners) {
|
|
80
80
|
for (const [event, listener] of listeners)
|
|
81
81
|
target.once(event, listener);
|
|
@@ -97,11 +97,13 @@ function observeWritableHeaders(message, span, direction, directHeaders) {
|
|
|
97
97
|
});
|
|
98
98
|
const restoreWriteHead = swapWriteHead(target, directHeaders, applyHeaders);
|
|
99
99
|
let removeListeners = () => { };
|
|
100
|
-
|
|
100
|
+
// Runs inside the customer's EventEmitter emit; a throw here would surface
|
|
101
|
+
// as an uncaughtException in their app.
|
|
102
|
+
const cleanup = () => (0, utils_js_1.safely)(() => {
|
|
101
103
|
restoreEnd();
|
|
102
104
|
restoreWriteHead();
|
|
103
105
|
removeListeners();
|
|
104
|
-
};
|
|
106
|
+
});
|
|
105
107
|
removeListeners = listenOnce(target, [
|
|
106
108
|
["finish", cleanup],
|
|
107
109
|
["close", cleanup],
|
|
@@ -121,8 +123,8 @@ function observeReadable(message, collector) {
|
|
|
121
123
|
});
|
|
122
124
|
return result;
|
|
123
125
|
});
|
|
124
|
-
const onComplete = () => collector.finalize(true);
|
|
125
|
-
const onIncomplete = () => collector.finalize(false);
|
|
126
|
+
const onComplete = () => (0, utils_js_1.safely)(() => collector.finalize(true));
|
|
127
|
+
const onIncomplete = () => (0, utils_js_1.safely)(() => collector.finalize(false));
|
|
126
128
|
const removeListeners = listenOnce(target, [
|
|
127
129
|
["end", onComplete],
|
|
128
130
|
["aborted", onIncomplete],
|
|
@@ -156,8 +158,8 @@ function observeWritable(message, collector, directHeaders) {
|
|
|
156
158
|
}
|
|
157
159
|
});
|
|
158
160
|
const restoreWriteHead = swapWriteHead(target, directHeaders);
|
|
159
|
-
const onComplete = () => collector.finalize(true);
|
|
160
|
-
const onIncomplete = () => collector.finalize(false);
|
|
161
|
+
const onComplete = () => (0, utils_js_1.safely)(() => collector.finalize(true));
|
|
162
|
+
const onIncomplete = () => (0, utils_js_1.safely)(() => collector.finalize(false));
|
|
161
163
|
const removeListeners = listenOnce(target, [
|
|
162
164
|
["finish", onComplete],
|
|
163
165
|
["close", onIncomplete],
|
|
@@ -170,19 +172,14 @@ function observeWritable(message, collector, directHeaders) {
|
|
|
170
172
|
removeListeners();
|
|
171
173
|
});
|
|
172
174
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
* captures the body via a BodyCollector.
|
|
177
|
-
*/
|
|
175
|
+
// pcga11: Instruments one HTTP message on a span:
|
|
176
|
+
// - records headers as `http.<direction>.header.*` attributes
|
|
177
|
+
// - captures the body via a BodyCollector when enabled.
|
|
178
178
|
function hookMessage(span, message, direction, bodyCaptureEnabled) {
|
|
179
|
-
// Duck-type: only outgoing messages (ClientRequest/ServerResponse) have write/end.
|
|
179
|
+
// pcga11: Duck-type: only outgoing messages (ClientRequest/ServerResponse) have write/end.
|
|
180
180
|
if ("write" in message && "end" in message) {
|
|
181
|
-
// A writable request is an outgoing client call; a writable response is served by us.
|
|
182
181
|
const side = direction === "request" ? "client" : "server";
|
|
183
|
-
// Collects headers passed inline to writeHead(), which don't show up in getHeaders().
|
|
184
182
|
const directHeaders = new Map();
|
|
185
|
-
// Eagerly record whatever headers exist right now; late-set headers are handled below.
|
|
186
183
|
(0, collector_js_1.setHeaderAttributes)(span, direction, outgoingHeaders(message, directHeaders));
|
|
187
184
|
const collector = bodyCaptureEnabled
|
|
188
185
|
? // Headers are passed as a lazy callback so late-set headers are seen at finalize time.
|
|
@@ -192,32 +189,33 @@ function hookMessage(span, message, direction, bodyCaptureEnabled) {
|
|
|
192
189
|
if (collector)
|
|
193
190
|
observeWritable(message, collector, directHeaders);
|
|
194
191
|
else if (span.isRecording()) {
|
|
195
|
-
// No body capture: still patch end/writeHead so late-set headers land on the span.
|
|
196
192
|
observeWritableHeaders(message, span, direction, directHeaders);
|
|
197
193
|
}
|
|
198
194
|
}
|
|
199
195
|
else {
|
|
200
|
-
// Inverse of the writable case: a request arriving at us means we are the server.
|
|
201
196
|
const side = direction === "request" ? "server" : "client";
|
|
202
|
-
// Incoming message: headers are fully parsed by Node before the hook runs, so snapshot once.
|
|
203
197
|
const headers = incomingHeaders(message);
|
|
204
198
|
(0, collector_js_1.setHeaderAttributes)(span, direction, headers);
|
|
205
199
|
const collector = bodyCaptureEnabled
|
|
206
200
|
? collector_js_1.BodyCollector.create(span, side, direction, () => headers)
|
|
207
201
|
: undefined;
|
|
208
|
-
// Patch the stream's push() to copy body chunks as they arrive from the socket.
|
|
209
202
|
if (collector)
|
|
210
203
|
observeReadable(message, collector);
|
|
211
204
|
}
|
|
212
205
|
}
|
|
213
|
-
|
|
206
|
+
// pcga11: Creates network capture hooks for node:http / node:https. Wraps instance
|
|
207
|
+
// methods: push(), write(), and end() on IncomingMessage / ClientRequest /
|
|
208
|
+
// ServerResponse. This works for all Node versions Foam supports.
|
|
214
209
|
function createNetworkCaptureHooks(bodyCaptureEnabled = true) {
|
|
210
|
+
// OTel guards hook errors today, but Foam's no-crash guarantee must not
|
|
211
|
+
// depend on that: any failure here is swallowed and the request proceeds
|
|
212
|
+
// as if Foam were not installed.
|
|
215
213
|
return {
|
|
216
214
|
requestHook(span, request) {
|
|
217
|
-
hookMessage(span, request, "request", bodyCaptureEnabled);
|
|
215
|
+
(0, utils_js_1.safely)(() => hookMessage(span, request, "request", bodyCaptureEnabled));
|
|
218
216
|
},
|
|
219
217
|
responseHook(span, response) {
|
|
220
|
-
hookMessage(span, response, "response", bodyCaptureEnabled);
|
|
218
|
+
(0, utils_js_1.safely)(() => hookMessage(span, response, "response", bodyCaptureEnabled));
|
|
221
219
|
},
|
|
222
220
|
};
|
|
223
221
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createUndiciNetworkCaptureHooks = exports.createNetworkCaptureHooks = void 0;
|
|
4
|
+
//
|
|
5
|
+
// Advanced body capture uses three adapters across Foam's Node range:
|
|
6
|
+
// 1. node:http / node:https — instance push(), write(), and end() wrapping
|
|
7
|
+
// (every supported Node version).
|
|
8
|
+
// 2. Undici legacy — request:create plus instance onBodySent / onData
|
|
9
|
+
// (bundled fetch on Node 18.19, 20, 22, and 24.0–24.3, whose Undici is
|
|
10
|
+
// older than 7.11).
|
|
11
|
+
// 3. Undici modern — bodyChunkSent / bodyChunkReceived channels (Undici
|
|
12
|
+
// 7.11+, including Node 24.4+ and a separately installed undici). Future
|
|
13
|
+
// Node is chosen by capability: modern channels win when they fire.
|
|
4
14
|
var http_js_1 = require("./http.js");
|
|
5
15
|
Object.defineProperty(exports, "createNetworkCaptureHooks", { enumerable: true, get: function () { return http_js_1.createNetworkCaptureHooks; } });
|
|
6
16
|
var undici_js_1 = require("./undici.js");
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSensitiveBodyKey = isSensitiveBodyKey;
|
|
4
|
+
exports.redactBodyText = redactBodyText;
|
|
5
|
+
const constants_js_1 = require("../constants.js");
|
|
6
|
+
const EXACT_KEYS = new Set(constants_js_1.SENSITIVE_BODY_KEYS_EXACT);
|
|
7
|
+
function normalizeKey(key) {
|
|
8
|
+
return key.toLowerCase().replace(/[-_.\s]/g, "");
|
|
9
|
+
}
|
|
10
|
+
function isSensitiveBodyKey(key) {
|
|
11
|
+
const normalized = normalizeKey(key);
|
|
12
|
+
return (EXACT_KEYS.has(normalized) ||
|
|
13
|
+
constants_js_1.SENSITIVE_BODY_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)));
|
|
14
|
+
}
|
|
15
|
+
function mediaType(contentType) {
|
|
16
|
+
const raw = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
17
|
+
if (typeof raw !== "string")
|
|
18
|
+
return "";
|
|
19
|
+
return (raw.split(";")[0] ?? "").trim().toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
function redactNode(node, state) {
|
|
22
|
+
if (Array.isArray(node)) {
|
|
23
|
+
return node.map((item) => redactNode(item, state));
|
|
24
|
+
}
|
|
25
|
+
if (node !== null && typeof node === "object") {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const [key, value] of Object.entries(node)) {
|
|
28
|
+
if (isSensitiveBodyKey(key) && value !== null && value !== undefined) {
|
|
29
|
+
result[key] = constants_js_1.REDACTED_VALUE;
|
|
30
|
+
state.changed = true;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
result[key] = redactNode(value, state);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
return node;
|
|
39
|
+
}
|
|
40
|
+
// pcga11: Captures a "key": value pair with a string (possibly unterminated
|
|
41
|
+
// at a truncation point), number, or literal value. Used only when the body
|
|
42
|
+
// does not parse as JSON, e.g. because the 1 MiB cap cut it mid-document.
|
|
43
|
+
const JSON_PAIR_PATTERN = /"((?:[^"\\]|\\.)*)"(\s*:\s*)("(?:[^"\\]|\\.)*"?|-?\d[\d.eE+-]*|true|false|null)/g;
|
|
44
|
+
function redactJsonFallback(text) {
|
|
45
|
+
let changed = false;
|
|
46
|
+
const redacted = text.replace(JSON_PAIR_PATTERN, (match, key, separator) => {
|
|
47
|
+
if (!isSensitiveBodyKey(key))
|
|
48
|
+
return match;
|
|
49
|
+
changed = true;
|
|
50
|
+
return `"${key}"${separator}"${constants_js_1.REDACTED_VALUE}"`;
|
|
51
|
+
});
|
|
52
|
+
return changed ? { text: redacted, changed } : { text, changed: false };
|
|
53
|
+
}
|
|
54
|
+
function redactJson(text) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return redactJsonFallback(text);
|
|
61
|
+
}
|
|
62
|
+
const state = { changed: false };
|
|
63
|
+
const redacted = redactNode(parsed, state);
|
|
64
|
+
if (!state.changed)
|
|
65
|
+
return { text, changed: false };
|
|
66
|
+
return { text: JSON.stringify(redacted), changed: true };
|
|
67
|
+
}
|
|
68
|
+
function redactForm(text) {
|
|
69
|
+
const params = new URLSearchParams(text);
|
|
70
|
+
let changed = false;
|
|
71
|
+
for (const key of new Set(params.keys())) {
|
|
72
|
+
if (!isSensitiveBodyKey(key))
|
|
73
|
+
continue;
|
|
74
|
+
params.set(key, constants_js_1.REDACTED_VALUE);
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
return changed ? { text: params.toString(), changed } : { text, changed: false };
|
|
78
|
+
}
|
|
79
|
+
function redactBodyText(text, contentType) {
|
|
80
|
+
const media = mediaType(contentType);
|
|
81
|
+
if (media === "application/x-www-form-urlencoded")
|
|
82
|
+
return redactForm(text);
|
|
83
|
+
if (media.endsWith("/json") || media.endsWith("+json")) {
|
|
84
|
+
return redactJson(text);
|
|
85
|
+
}
|
|
86
|
+
return { text, changed: false };
|
|
87
|
+
}
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { RequestHookFunction, ResponseHookFunction } from "@opentelemetry/instrumentation-undici";
|
|
2
|
-
/**
|
|
3
|
-
* Builds OTel Undici/fetch hooks. Headers use the same allowlist as Node HTTP.
|
|
4
|
-
* Bodies are copied from diagnostics_channel chunks — never from fetch streams.
|
|
5
|
-
*/
|
|
6
2
|
export declare function createUndiciNetworkCaptureHooks(bodyCaptureEnabled?: boolean): {
|
|
7
3
|
requestHook: RequestHookFunction;
|
|
8
4
|
responseHook: ResponseHookFunction;
|
|
@@ -6,6 +6,7 @@ const node_diagnostics_channel_1 = require("node:diagnostics_channel");
|
|
|
6
6
|
const utils_js_1 = require("../utils.js");
|
|
7
7
|
const collector_js_1 = require("./collector.js");
|
|
8
8
|
const undiciSessions = new WeakMap();
|
|
9
|
+
const legacyWrappedRequests = new WeakSet();
|
|
9
10
|
let undiciBodyChannelsBound = false;
|
|
10
11
|
function appendHeader(headers, rawName, value) {
|
|
11
12
|
const name = String(rawName).toLowerCase();
|
|
@@ -90,19 +91,50 @@ function bindUndiciChannel(name, handle) {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
}
|
|
94
|
+
function wrapLegacyBodyMethod(request, name, direction) {
|
|
95
|
+
const target = request;
|
|
96
|
+
const original = target[name];
|
|
97
|
+
if (typeof original !== "function")
|
|
98
|
+
return;
|
|
99
|
+
target[name] = function (chunk, ...rest) {
|
|
100
|
+
const result = original.call(this, chunk, ...rest);
|
|
101
|
+
(0, utils_js_1.safely)(() => {
|
|
102
|
+
const session = undiciSessions.get(request);
|
|
103
|
+
if (!session || session.modernBodyChannels)
|
|
104
|
+
return;
|
|
105
|
+
undiciCollector(session, direction)?.observe(chunk);
|
|
106
|
+
});
|
|
107
|
+
return result;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function bindLegacyUndiciBodyMethods(request) {
|
|
111
|
+
if (legacyWrappedRequests.has(request))
|
|
112
|
+
return;
|
|
113
|
+
legacyWrappedRequests.add(request);
|
|
114
|
+
wrapLegacyBodyMethod(request, "onBodySent", "request");
|
|
115
|
+
wrapLegacyBodyMethod(request, "onData", "response");
|
|
116
|
+
wrapLegacyBodyMethod(request, "onResponseData", "response");
|
|
117
|
+
}
|
|
93
118
|
function bindUndiciBodyChannels() {
|
|
94
119
|
if (undiciBodyChannelsBound)
|
|
95
120
|
return;
|
|
96
121
|
undiciBodyChannelsBound = true;
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
(0, node_diagnostics_channel_1.subscribe)("undici:request:create", (message) => {
|
|
123
|
+
(0, utils_js_1.safely)(() => {
|
|
124
|
+
const request = undiciRequest(message);
|
|
125
|
+
if (request)
|
|
126
|
+
bindLegacyUndiciBodyMethods(request);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
99
129
|
bindUndiciChannel("undici:request:bodyChunkSent", (message, _request, session) => {
|
|
130
|
+
session.modernBodyChannels = true;
|
|
100
131
|
undiciCollector(session, "request")?.observe(message.chunk);
|
|
101
132
|
});
|
|
102
133
|
bindUndiciChannel("undici:request:bodySent", (_message, _request, session) => {
|
|
103
134
|
session.requestBody?.finalize(true);
|
|
104
135
|
});
|
|
105
136
|
bindUndiciChannel("undici:request:bodyChunkReceived", (message, _request, session) => {
|
|
137
|
+
session.modernBodyChannels = true;
|
|
106
138
|
undiciCollector(session, "response")?.observe(message.chunk);
|
|
107
139
|
});
|
|
108
140
|
bindUndiciChannel("undici:request:trailers", (_message, request) => {
|
|
@@ -112,33 +144,34 @@ function bindUndiciBodyChannels() {
|
|
|
112
144
|
finishUndici(request, false);
|
|
113
145
|
});
|
|
114
146
|
}
|
|
115
|
-
/**
|
|
116
|
-
* Builds OTel Undici/fetch hooks. Headers use the same allowlist as Node HTTP.
|
|
117
|
-
* Bodies are copied from diagnostics_channel chunks — never from fetch streams.
|
|
118
|
-
*/
|
|
119
147
|
function createUndiciNetworkCaptureHooks(bodyCaptureEnabled = true) {
|
|
120
148
|
if (bodyCaptureEnabled)
|
|
121
149
|
bindUndiciBodyChannels();
|
|
122
150
|
return {
|
|
123
151
|
requestHook(span, request) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
!
|
|
128
|
-
|
|
129
|
-
api_1.TraceFlags.SAMPLED)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
(0, utils_js_1.safely)(() => {
|
|
153
|
+
const headers = undiciRequestHeaders(request.headers);
|
|
154
|
+
(0, collector_js_1.setHeaderAttributes)(span, "request", headers);
|
|
155
|
+
if (!bodyCaptureEnabled ||
|
|
156
|
+
!span.isRecording() ||
|
|
157
|
+
(span.spanContext().traceFlags & api_1.TraceFlags.SAMPLED) !==
|
|
158
|
+
api_1.TraceFlags.SAMPLED) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
undiciSessions.set(request, { span, requestHeaders: headers });
|
|
162
|
+
bindLegacyUndiciBodyMethods(request);
|
|
163
|
+
});
|
|
133
164
|
},
|
|
134
165
|
responseHook(span, info) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
session
|
|
166
|
+
(0, utils_js_1.safely)(() => {
|
|
167
|
+
const headers = undiciResponseHeaders(info.response.headers);
|
|
168
|
+
(0, collector_js_1.setHeaderAttributes)(span, "response", headers);
|
|
169
|
+
if (!bodyCaptureEnabled || info.request === undefined)
|
|
170
|
+
return;
|
|
171
|
+
const session = undiciSessions.get(info.request);
|
|
172
|
+
if (session)
|
|
173
|
+
session.responseHeaders = headers;
|
|
174
|
+
});
|
|
142
175
|
},
|
|
143
176
|
};
|
|
144
177
|
}
|
package/dist/otlp.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SeverityNumber } from "@opentelemetry/api-logs";
|
|
2
|
+
export declare function sendOtlpLog({ token, resourceAttributes, scopeName, severityNumber, body, }: {
|
|
3
|
+
token: string;
|
|
4
|
+
resourceAttributes: Record<string, string>;
|
|
5
|
+
scopeName: string;
|
|
6
|
+
severityNumber: SeverityNumber;
|
|
7
|
+
body: string;
|
|
8
|
+
}): Promise<void>;
|