@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.
@@ -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
- const enc = typeof encoding === "string" && Buffer.isEncoding(encoding)
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 text = media.startsWith("text/") ||
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 (!text)
126
+ if (!isText)
110
127
  return undefined;
111
- return charset && Buffer.isEncoding(charset) ? charset : "utf8";
128
+ if (charset && Buffer.isEncoding(charset))
129
+ return charset;
130
+ return "utf8";
112
131
  }
113
- /** Last index such that `buf.subarray(0, end)` is complete UTF-8, capped at `max`. */
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
- // pcga11: Span attributes cannot carry byte arrays (OTel AttributeValue).
136
- // Text goes on the span; binary uses the same `http.*.body.content` name on
137
- // the correlated event, where log attributes accept Uint8Array.
138
- function spanBodyContent(decoded, contentType) {
139
- const encoding = textEncoding(contentType);
140
- if (encoding === undefined)
141
- return undefined;
142
- const limited = decoded.byteLength > constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES
143
- ? decoded.subarray(0, constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES)
144
- : decoded;
145
- const end = encoding === "utf8" || encoding === "utf-8"
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
- const outcome = this.observedBytes === 0
240
- ? "empty"
241
- : complete
242
- ? "captured"
243
- : "incomplete";
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
- const wire = buffers.length <= 1 ? buffers[0] : Buffer.concat(buffers);
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
- if (wire) {
285
+ let chunkSource = wire;
286
+ if (wire !== undefined) {
249
287
  const decoded = decodeBody(wire, headers["content-encoding"]);
250
- const source = decoded ?? wire;
251
- const text = decoded
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 limited = source.byteLength > constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES
259
- ? source.subarray(0, constants_js_1.NETWORK_BODY_MAX_CAPTURE_BYTES)
260
- : source;
261
- binaryContent = Uint8Array.from(limited);
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 (wire) {
266
- for (let i = 0; i < wire.byteLength; i += constants_js_1.NETWORK_BODY_CHUNK_BYTES) {
267
- chunks.push(wire.subarray(i, i + constants_js_1.NETWORK_BODY_CHUNK_BYTES));
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
- /** Replaces a method with a wrapped version; returns an undo that restores it unless someone else re-patched it. */
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
- /** Wraps writeHead (when present) to record inline headers; returns an undo. */
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
- /** Attaches one-shot listeners; returns an undo that removes all of them. */
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
- const cleanup = () => {
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
- * Instruments one HTTP message (either side, either direction) on a span:
175
- * records headers as `http.<direction>.header.*` attributes and, when enabled,
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
- /** Builds OTel HTTP request/response hooks that record headers as span attributes and stream bodies as log events. */
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,6 @@
1
+ export interface RedactionResult {
2
+ readonly text: string;
3
+ readonly changed: boolean;
4
+ }
5
+ export declare function isSensitiveBodyKey(key: string): boolean;
6
+ export declare function redactBodyText(text: string, contentType: unknown): RedactionResult;
@@ -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
- // Subscribe before UndiciInstrumentation.enable() so trailers run while
98
- // the span is still recording. BodyCollector is created on first chunk.
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
- const headers = undiciRequestHeaders(request.headers);
125
- (0, collector_js_1.setHeaderAttributes)(span, "request", headers);
126
- if (!bodyCaptureEnabled ||
127
- !span.isRecording() ||
128
- (span.spanContext().traceFlags & api_1.TraceFlags.SAMPLED) !==
129
- api_1.TraceFlags.SAMPLED) {
130
- return;
131
- }
132
- undiciSessions.set(request, { span, requestHeaders: headers });
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
- const headers = undiciResponseHeaders(info.response.headers);
136
- (0, collector_js_1.setHeaderAttributes)(span, "response", headers);
137
- if (!bodyCaptureEnabled || info.request === undefined)
138
- return;
139
- const session = undiciSessions.get(info.request);
140
- if (session)
141
- session.responseHeaders = headers;
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>;