@clickhouse/clickstack-node 0.0.0-stage → 1.0.0-rc.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 +203 -0
- package/README.md +282 -2
- package/dist/_virtual/_rolldown/runtime.cjs +22 -0
- package/dist/body/http.cjs +115 -0
- package/dist/body/http.mjs +115 -0
- package/dist/body/rules.cjs +225 -0
- package/dist/body/rules.mjs +220 -0
- package/dist/body/undici.cjs +88 -0
- package/dist/body/undici.mjs +87 -0
- package/dist/compose.cjs +107 -0
- package/dist/compose.mjs +107 -0
- package/dist/config.cjs +27 -0
- package/dist/config.d.cts +61 -0
- package/dist/config.d.mts +61 -0
- package/dist/config.mjs +27 -0
- package/dist/env.cjs +88 -0
- package/dist/env.mjs +84 -0
- package/dist/exception.cjs +54 -0
- package/dist/exception.mjs +54 -0
- package/dist/hook.cjs +47 -0
- package/dist/hook.mjs +45 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +3 -0
- package/dist/init.cjs +139 -0
- package/dist/init.d.cts +28 -0
- package/dist/init.d.mts +28 -0
- package/dist/init.mjs +137 -0
- package/dist/lifecycle.cjs +249 -0
- package/dist/lifecycle.d.cts +68 -0
- package/dist/lifecycle.d.mts +68 -0
- package/dist/lifecycle.mjs +245 -0
- package/dist/register.cjs +12 -0
- package/dist/register.d.cts +1 -0
- package/dist/register.d.mts +1 -0
- package/dist/register.mjs +14 -0
- package/dist/semconv.cjs +16 -0
- package/dist/semconv.mjs +13 -0
- package/dist/state.cjs +12 -0
- package/dist/state.mjs +12 -0
- package/dist/version.cjs +4 -0
- package/dist/version.mjs +4 -0
- package/package.json +114 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { BodyCapture, headerMap, reportFailure, responseHasNoBody } from "./rules.mjs";
|
|
2
|
+
import { errorMonitor } from "node:events";
|
|
3
|
+
// src/body/http.ts
|
|
4
|
+
function isIncoming(message) {
|
|
5
|
+
return !("getHeader" in message);
|
|
6
|
+
}
|
|
7
|
+
function observe(message) {
|
|
8
|
+
const capture = new BodyCapture();
|
|
9
|
+
const copy = (chunk, encoding) => {
|
|
10
|
+
try {
|
|
11
|
+
capture.add(chunk, encoding);
|
|
12
|
+
} catch (err) {
|
|
13
|
+
reportFailure(err);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
if (isIncoming(message)) {
|
|
17
|
+
const { headers } = message;
|
|
18
|
+
const meta = {
|
|
19
|
+
contentType: headers["content-type"],
|
|
20
|
+
contentEncoding: headers["content-encoding"],
|
|
21
|
+
contentLength: headers["content-length"]
|
|
22
|
+
};
|
|
23
|
+
const emit = message.emit.bind(message);
|
|
24
|
+
message.emit = (event, ...args) => {
|
|
25
|
+
if (event === "data") copy(args[0], message.readableEncoding);
|
|
26
|
+
return emit(event, ...args);
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
capture,
|
|
30
|
+
meta: () => ({
|
|
31
|
+
...meta,
|
|
32
|
+
complete: message.complete
|
|
33
|
+
})
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const wrap = (original) => (...args) => {
|
|
37
|
+
const ended = message.writableEnded;
|
|
38
|
+
const result = Reflect.apply(original, message, args);
|
|
39
|
+
if (!ended && typeof args[0] !== "function") copy(args[0], args[1]);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
message.write = wrap(message.write.bind(message));
|
|
43
|
+
message.end = wrap(message.end.bind(message));
|
|
44
|
+
let written = /* @__PURE__ */ new Map();
|
|
45
|
+
if ("writeHead" in message) {
|
|
46
|
+
const response = message;
|
|
47
|
+
const writeHead = response.writeHead.bind(response);
|
|
48
|
+
response.writeHead = ((...args) => {
|
|
49
|
+
const result = writeHead(...args);
|
|
50
|
+
try {
|
|
51
|
+
const raw = args.slice(1).find((a) => typeof a === "object" && a !== null);
|
|
52
|
+
const list = Array.isArray(raw) ? raw.flat() : Object.entries(raw ?? {}).flat();
|
|
53
|
+
if (list.length > 0) written = headerMap(list);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
reportFailure(err);
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const header = (name) => message.getHeader(name) ?? written.get(name);
|
|
61
|
+
return {
|
|
62
|
+
capture,
|
|
63
|
+
meta: () => ({
|
|
64
|
+
contentType: header("content-type"),
|
|
65
|
+
contentEncoding: header("content-encoding"),
|
|
66
|
+
contentLength: header("content-length"),
|
|
67
|
+
complete: message.writableEnded
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Creates the `node:http` body hooks.
|
|
73
|
+
*
|
|
74
|
+
* @returns Hooks for `instrumentation-http`.
|
|
75
|
+
*/
|
|
76
|
+
function createHttpBodyHooks() {
|
|
77
|
+
const exchanges = /* @__PURE__ */ new WeakMap();
|
|
78
|
+
const finalize = (span) => {
|
|
79
|
+
try {
|
|
80
|
+
const ex = exchanges.get(span);
|
|
81
|
+
exchanges.delete(span);
|
|
82
|
+
if (ex === void 0 || !span.isRecording()) return;
|
|
83
|
+
span.setAttributes(ex.request.capture.attributes("request", ex.request.meta()));
|
|
84
|
+
if (ex.response !== void 0 && !responseHasNoBody(ex.method, ex.status?.())) span.setAttributes(ex.response.capture.attributes("response", ex.response.meta()));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
reportFailure(err);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
requestHook(span, request) {
|
|
91
|
+
if (!span.isRecording()) return;
|
|
92
|
+
exchanges.set(span, {
|
|
93
|
+
method: request.method,
|
|
94
|
+
request: observe(request)
|
|
95
|
+
});
|
|
96
|
+
if (!isIncoming(request)) {
|
|
97
|
+
const done = () => {
|
|
98
|
+
finalize(span);
|
|
99
|
+
};
|
|
100
|
+
request.on(errorMonitor, done).on("close", done);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
responseHook(span, response) {
|
|
104
|
+
const ex = exchanges.get(span);
|
|
105
|
+
if (ex === void 0) return;
|
|
106
|
+
ex.response = observe(response);
|
|
107
|
+
ex.status = () => response.statusCode;
|
|
108
|
+
if (!isIncoming(response)) response.on(errorMonitor, () => {
|
|
109
|
+
finalize(span);
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
finalize
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export { createHttpBodyHooks };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const require_env = require("../env.cjs");
|
|
2
|
+
const require_semconv = require("../semconv.cjs");
|
|
3
|
+
let _opentelemetry_api = require("@opentelemetry/api");
|
|
4
|
+
let node_buffer = require("node:buffer");
|
|
5
|
+
let node_util = require("node:util");
|
|
6
|
+
// src/body/rules.ts
|
|
7
|
+
/** Bytes kept per body and direction. */
|
|
8
|
+
const MAX_BYTES = 8192;
|
|
9
|
+
/** Key substrings whose values are redacted, matched case-insensitively. */
|
|
10
|
+
const REDACT_KEYS = [
|
|
11
|
+
"password",
|
|
12
|
+
"passwd",
|
|
13
|
+
"secret",
|
|
14
|
+
"token",
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"api-key",
|
|
18
|
+
"authorization",
|
|
19
|
+
"cookie",
|
|
20
|
+
"session"
|
|
21
|
+
];
|
|
22
|
+
const REDACTED = "[REDACTED]";
|
|
23
|
+
const FORM = "application/x-www-form-urlencoded";
|
|
24
|
+
/**
|
|
25
|
+
* Reports a failure inside capture code: one warning per thread, then debug lines.
|
|
26
|
+
*
|
|
27
|
+
* @param err - The caught error.
|
|
28
|
+
*/
|
|
29
|
+
function reportFailure(err) {
|
|
30
|
+
require_env.warnOnce("capture-failed", "HTTP body capture failed and was skipped", err);
|
|
31
|
+
_opentelemetry_api.diag.debug("clickstack-node: HTTP body capture failed", err);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Tells whether a response carries no body on the wire, whatever its headers say.
|
|
35
|
+
*
|
|
36
|
+
* @param method - Request method.
|
|
37
|
+
* @param status - Response status code.
|
|
38
|
+
* @returns `true` for HEAD requests and 1xx, 204 and 304 responses.
|
|
39
|
+
*/
|
|
40
|
+
function responseHasNoBody(method, status) {
|
|
41
|
+
return method?.toUpperCase() === "HEAD" || status === 204 || status === 304 || (status ?? 200) < 200;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Collects a flat `[name, value, name, value]` header list (undici, or `writeHead` arguments).
|
|
45
|
+
*
|
|
46
|
+
* @param headers - The list; anything else gives an empty map.
|
|
47
|
+
* @returns Lowercased names mapped to their values, repeated names joined with `, `.
|
|
48
|
+
*/
|
|
49
|
+
function headerMap(headers) {
|
|
50
|
+
const map = /* @__PURE__ */ new Map();
|
|
51
|
+
const list = Array.isArray(headers) ? headers : [];
|
|
52
|
+
for (let i = 0; i + 1 < list.length; i += 2) {
|
|
53
|
+
const name = String(list[i]).toLowerCase();
|
|
54
|
+
const value = String(list[i + 1]);
|
|
55
|
+
const previous = map.get(name);
|
|
56
|
+
map.set(name, previous === void 0 ? value : `${previous}, ${value}`);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
function headerString(value) {
|
|
61
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
62
|
+
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
|
|
63
|
+
}
|
|
64
|
+
function isTextualUtf8(contentType) {
|
|
65
|
+
const [essence = "", ...params] = headerString(contentType).toLowerCase().split(";");
|
|
66
|
+
const type = essence.trim();
|
|
67
|
+
const charset = params.map((p) => p.trim()).find((p) => p.startsWith("charset="));
|
|
68
|
+
if (type === "" || type.startsWith("multipart/")) return false;
|
|
69
|
+
if (charset !== void 0) return /^charset="?utf-?8"?$/.test(charset);
|
|
70
|
+
return type.startsWith("text/") || /[/+](json|xml|yaml)$/.test(type) || type === FORM;
|
|
71
|
+
}
|
|
72
|
+
function isSensitive(key) {
|
|
73
|
+
const lower = key.toLowerCase();
|
|
74
|
+
return REDACT_KEYS.some((k) => lower.includes(k));
|
|
75
|
+
}
|
|
76
|
+
function skipSpace(text, from) {
|
|
77
|
+
let i = from;
|
|
78
|
+
while (i < text.length && /\s/.test(text.charAt(i))) i++;
|
|
79
|
+
return i;
|
|
80
|
+
}
|
|
81
|
+
/** Index just past the string token that starts at `from`, or -1 when the cap cut it. */
|
|
82
|
+
function endOfString(text, from) {
|
|
83
|
+
for (let i = from + 1; i < text.length; i++) if (text[i] === "\\") i++;
|
|
84
|
+
else if (text[i] === "\"") return i + 1;
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
/** Index just past the JSON value that starts at `from`; the text length when the cap cut it. */
|
|
88
|
+
function endOfValue(text, from) {
|
|
89
|
+
let depth = 0;
|
|
90
|
+
for (let i = from; i < text.length; i++) {
|
|
91
|
+
const c = text.charAt(i);
|
|
92
|
+
if (c === "\"") {
|
|
93
|
+
const end = endOfString(text, i);
|
|
94
|
+
if (end === -1) return text.length;
|
|
95
|
+
if (depth === 0) return end;
|
|
96
|
+
i = end - 1;
|
|
97
|
+
} else if (c === "{" || c === "[") depth++;
|
|
98
|
+
else if (c === "}" || c === "]") {
|
|
99
|
+
if (depth === 0) return i;
|
|
100
|
+
if (--depth === 0) return i + 1;
|
|
101
|
+
} else if (depth === 0 && (c === "," || /\s/.test(c))) return i;
|
|
102
|
+
}
|
|
103
|
+
return text.length;
|
|
104
|
+
}
|
|
105
|
+
const ESCAPES = {
|
|
106
|
+
b: "\b",
|
|
107
|
+
f: "\f",
|
|
108
|
+
n: "\n",
|
|
109
|
+
r: "\r",
|
|
110
|
+
t: " "
|
|
111
|
+
};
|
|
112
|
+
function unescapeJson(token) {
|
|
113
|
+
return token.slice(1, -1).replace(/\\(?:u([0-9a-fA-F]{4})|([^]))/g, (_match, hex, char) => hex !== void 0 ? String.fromCharCode(parseInt(hex, 16)) : ESCAPES[char ?? ""] ?? char ?? "");
|
|
114
|
+
}
|
|
115
|
+
function redactJson(text, nested = false) {
|
|
116
|
+
let out = "";
|
|
117
|
+
let copied = 0;
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
if (text[i] !== "\"") {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const end = endOfString(text, i);
|
|
125
|
+
if (end === -1) break;
|
|
126
|
+
const token = text.slice(i, end);
|
|
127
|
+
const colon = skipSpace(text, end);
|
|
128
|
+
if (text[colon] !== ":") {
|
|
129
|
+
if (!nested && token.includes("\\\"")) {
|
|
130
|
+
const inner = unescapeJson(token);
|
|
131
|
+
const redacted = redactJson(inner, true);
|
|
132
|
+
if (redacted !== inner) {
|
|
133
|
+
out += text.slice(copied, i) + JSON.stringify(redacted);
|
|
134
|
+
copied = end;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
i = end;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const start = skipSpace(text, colon + 1);
|
|
141
|
+
if (start === text.length || !isSensitive(unescapeJson(token))) {
|
|
142
|
+
i = end;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out += `${text.slice(copied, start)}"${REDACTED}"`;
|
|
146
|
+
i = copied = endOfValue(text, start);
|
|
147
|
+
}
|
|
148
|
+
return out + text.slice(copied);
|
|
149
|
+
}
|
|
150
|
+
function formKey(raw) {
|
|
151
|
+
return raw.replaceAll("+", " ").replace(/%([0-9a-fA-F]{2})/g, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
152
|
+
}
|
|
153
|
+
function redactForm(text) {
|
|
154
|
+
return text.split("&").map((pair) => {
|
|
155
|
+
const eq = pair.indexOf("=");
|
|
156
|
+
if (eq === -1) return pair;
|
|
157
|
+
const key = pair.slice(0, eq);
|
|
158
|
+
return isSensitive(formKey(key)) ? `${key}=${REDACTED}` : pair;
|
|
159
|
+
}).join("&");
|
|
160
|
+
}
|
|
161
|
+
/** Copies the first {@link MAX_BYTES} bytes of one body and counts every byte seen. */
|
|
162
|
+
var BodyCapture = class {
|
|
163
|
+
/** Bytes seen, including those past the cap. */
|
|
164
|
+
seen = 0;
|
|
165
|
+
#kept = 0;
|
|
166
|
+
#buffer;
|
|
167
|
+
/**
|
|
168
|
+
* Adds one chunk; values other than strings and byte arrays are ignored.
|
|
169
|
+
*
|
|
170
|
+
* @param chunk - `Buffer`, `Uint8Array` or `string`.
|
|
171
|
+
* @param encoding - Encoding of a string chunk; anything else means UTF-8.
|
|
172
|
+
*/
|
|
173
|
+
add(chunk, encoding) {
|
|
174
|
+
if (typeof chunk === "string") {
|
|
175
|
+
const enc = typeof encoding === "string" && node_buffer.Buffer.isEncoding(encoding) ? encoding : "utf8";
|
|
176
|
+
this.seen += node_buffer.Buffer.byteLength(chunk, enc);
|
|
177
|
+
if (this.#kept >= 8192) return;
|
|
178
|
+
const head = /^(hex|base64(url)?)$/i.test(enc) ? chunk : chunk.slice(0, MAX_BYTES - this.#kept + 1);
|
|
179
|
+
this.#copy(node_buffer.Buffer.from(head, enc));
|
|
180
|
+
} else if (chunk instanceof Uint8Array) {
|
|
181
|
+
this.seen += chunk.byteLength;
|
|
182
|
+
this.#copy(chunk);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
#copy(bytes) {
|
|
186
|
+
const n = Math.min(MAX_BYTES - this.#kept, bytes.byteLength);
|
|
187
|
+
if (n <= 0) return;
|
|
188
|
+
const need = this.#kept + n;
|
|
189
|
+
if (this.#buffer === void 0 || this.#buffer.length < need) {
|
|
190
|
+
const size = Math.max(need, 2 * (this.#buffer?.length ?? 0));
|
|
191
|
+
const grown = node_buffer.Buffer.alloc(Math.min(size, MAX_BYTES));
|
|
192
|
+
this.#buffer?.copy(grown, 0, 0, this.#kept);
|
|
193
|
+
this.#buffer = grown;
|
|
194
|
+
}
|
|
195
|
+
this.#buffer.set(bytes.subarray(0, n), this.#kept);
|
|
196
|
+
this.#kept = need;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Builds the size and content attributes for this body.
|
|
200
|
+
*
|
|
201
|
+
* @param direction - Which side of the exchange the body belongs to.
|
|
202
|
+
* @param meta - The body's headers and transfer state.
|
|
203
|
+
* @returns The attributes; empty for an empty body.
|
|
204
|
+
*/
|
|
205
|
+
attributes(direction, meta) {
|
|
206
|
+
const length = headerString(meta.contentLength);
|
|
207
|
+
const size = /^\d+$/.test(length) && (meta.complete || this.seen === 0) ? Number(length) : this.seen;
|
|
208
|
+
if (size === 0) return {};
|
|
209
|
+
const request = direction === "request";
|
|
210
|
+
const attributes = { [request ? require_semconv.ATTR_HTTP_REQUEST_BODY_SIZE : require_semconv.ATTR_HTTP_RESPONSE_BODY_SIZE]: size };
|
|
211
|
+
const coding = headerString(meta.contentEncoding).toLowerCase();
|
|
212
|
+
if (this.#buffer === void 0 || coding !== "" && coding !== "identity") return attributes;
|
|
213
|
+
if (!isTextualUtf8(meta.contentType)) return attributes;
|
|
214
|
+
let text = new node_util.TextDecoder().decode(this.#buffer.subarray(0, this.#kept), { stream: this.seen > this.#kept });
|
|
215
|
+
if (headerString(meta.contentType).toLowerCase().startsWith(FORM)) text = redactForm(text);
|
|
216
|
+
attributes[request ? require_semconv.ATTR_HTTP_REQUEST_BODY_CONTENT : require_semconv.ATTR_HTTP_RESPONSE_BODY_CONTENT] = redactJson(text);
|
|
217
|
+
return attributes;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
exports.BodyCapture = BodyCapture;
|
|
221
|
+
exports.MAX_BYTES = MAX_BYTES;
|
|
222
|
+
exports.REDACT_KEYS = REDACT_KEYS;
|
|
223
|
+
exports.headerMap = headerMap;
|
|
224
|
+
exports.reportFailure = reportFailure;
|
|
225
|
+
exports.responseHasNoBody = responseHasNoBody;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { warnOnce } from "../env.mjs";
|
|
2
|
+
import { ATTR_HTTP_REQUEST_BODY_CONTENT, ATTR_HTTP_REQUEST_BODY_SIZE, ATTR_HTTP_RESPONSE_BODY_CONTENT, ATTR_HTTP_RESPONSE_BODY_SIZE } from "../semconv.mjs";
|
|
3
|
+
import { diag } from "@opentelemetry/api";
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { TextDecoder } from "node:util";
|
|
6
|
+
// src/body/rules.ts
|
|
7
|
+
/** Bytes kept per body and direction. */
|
|
8
|
+
const MAX_BYTES = 8192;
|
|
9
|
+
/** Key substrings whose values are redacted, matched case-insensitively. */
|
|
10
|
+
const REDACT_KEYS = [
|
|
11
|
+
"password",
|
|
12
|
+
"passwd",
|
|
13
|
+
"secret",
|
|
14
|
+
"token",
|
|
15
|
+
"api_key",
|
|
16
|
+
"apikey",
|
|
17
|
+
"api-key",
|
|
18
|
+
"authorization",
|
|
19
|
+
"cookie",
|
|
20
|
+
"session"
|
|
21
|
+
];
|
|
22
|
+
const REDACTED = "[REDACTED]";
|
|
23
|
+
const FORM = "application/x-www-form-urlencoded";
|
|
24
|
+
/**
|
|
25
|
+
* Reports a failure inside capture code: one warning per thread, then debug lines.
|
|
26
|
+
*
|
|
27
|
+
* @param err - The caught error.
|
|
28
|
+
*/
|
|
29
|
+
function reportFailure(err) {
|
|
30
|
+
warnOnce("capture-failed", "HTTP body capture failed and was skipped", err);
|
|
31
|
+
diag.debug("clickstack-node: HTTP body capture failed", err);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Tells whether a response carries no body on the wire, whatever its headers say.
|
|
35
|
+
*
|
|
36
|
+
* @param method - Request method.
|
|
37
|
+
* @param status - Response status code.
|
|
38
|
+
* @returns `true` for HEAD requests and 1xx, 204 and 304 responses.
|
|
39
|
+
*/
|
|
40
|
+
function responseHasNoBody(method, status) {
|
|
41
|
+
return method?.toUpperCase() === "HEAD" || status === 204 || status === 304 || (status ?? 200) < 200;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Collects a flat `[name, value, name, value]` header list (undici, or `writeHead` arguments).
|
|
45
|
+
*
|
|
46
|
+
* @param headers - The list; anything else gives an empty map.
|
|
47
|
+
* @returns Lowercased names mapped to their values, repeated names joined with `, `.
|
|
48
|
+
*/
|
|
49
|
+
function headerMap(headers) {
|
|
50
|
+
const map = /* @__PURE__ */ new Map();
|
|
51
|
+
const list = Array.isArray(headers) ? headers : [];
|
|
52
|
+
for (let i = 0; i + 1 < list.length; i += 2) {
|
|
53
|
+
const name = String(list[i]).toLowerCase();
|
|
54
|
+
const value = String(list[i + 1]);
|
|
55
|
+
const previous = map.get(name);
|
|
56
|
+
map.set(name, previous === void 0 ? value : `${previous}, ${value}`);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
function headerString(value) {
|
|
61
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
62
|
+
return typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
|
|
63
|
+
}
|
|
64
|
+
function isTextualUtf8(contentType) {
|
|
65
|
+
const [essence = "", ...params] = headerString(contentType).toLowerCase().split(";");
|
|
66
|
+
const type = essence.trim();
|
|
67
|
+
const charset = params.map((p) => p.trim()).find((p) => p.startsWith("charset="));
|
|
68
|
+
if (type === "" || type.startsWith("multipart/")) return false;
|
|
69
|
+
if (charset !== void 0) return /^charset="?utf-?8"?$/.test(charset);
|
|
70
|
+
return type.startsWith("text/") || /[/+](json|xml|yaml)$/.test(type) || type === FORM;
|
|
71
|
+
}
|
|
72
|
+
function isSensitive(key) {
|
|
73
|
+
const lower = key.toLowerCase();
|
|
74
|
+
return REDACT_KEYS.some((k) => lower.includes(k));
|
|
75
|
+
}
|
|
76
|
+
function skipSpace(text, from) {
|
|
77
|
+
let i = from;
|
|
78
|
+
while (i < text.length && /\s/.test(text.charAt(i))) i++;
|
|
79
|
+
return i;
|
|
80
|
+
}
|
|
81
|
+
/** Index just past the string token that starts at `from`, or -1 when the cap cut it. */
|
|
82
|
+
function endOfString(text, from) {
|
|
83
|
+
for (let i = from + 1; i < text.length; i++) if (text[i] === "\\") i++;
|
|
84
|
+
else if (text[i] === "\"") return i + 1;
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
/** Index just past the JSON value that starts at `from`; the text length when the cap cut it. */
|
|
88
|
+
function endOfValue(text, from) {
|
|
89
|
+
let depth = 0;
|
|
90
|
+
for (let i = from; i < text.length; i++) {
|
|
91
|
+
const c = text.charAt(i);
|
|
92
|
+
if (c === "\"") {
|
|
93
|
+
const end = endOfString(text, i);
|
|
94
|
+
if (end === -1) return text.length;
|
|
95
|
+
if (depth === 0) return end;
|
|
96
|
+
i = end - 1;
|
|
97
|
+
} else if (c === "{" || c === "[") depth++;
|
|
98
|
+
else if (c === "}" || c === "]") {
|
|
99
|
+
if (depth === 0) return i;
|
|
100
|
+
if (--depth === 0) return i + 1;
|
|
101
|
+
} else if (depth === 0 && (c === "," || /\s/.test(c))) return i;
|
|
102
|
+
}
|
|
103
|
+
return text.length;
|
|
104
|
+
}
|
|
105
|
+
const ESCAPES = {
|
|
106
|
+
b: "\b",
|
|
107
|
+
f: "\f",
|
|
108
|
+
n: "\n",
|
|
109
|
+
r: "\r",
|
|
110
|
+
t: " "
|
|
111
|
+
};
|
|
112
|
+
function unescapeJson(token) {
|
|
113
|
+
return token.slice(1, -1).replace(/\\(?:u([0-9a-fA-F]{4})|([^]))/g, (_match, hex, char) => hex !== void 0 ? String.fromCharCode(parseInt(hex, 16)) : ESCAPES[char ?? ""] ?? char ?? "");
|
|
114
|
+
}
|
|
115
|
+
function redactJson(text, nested = false) {
|
|
116
|
+
let out = "";
|
|
117
|
+
let copied = 0;
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < text.length) {
|
|
120
|
+
if (text[i] !== "\"") {
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const end = endOfString(text, i);
|
|
125
|
+
if (end === -1) break;
|
|
126
|
+
const token = text.slice(i, end);
|
|
127
|
+
const colon = skipSpace(text, end);
|
|
128
|
+
if (text[colon] !== ":") {
|
|
129
|
+
if (!nested && token.includes("\\\"")) {
|
|
130
|
+
const inner = unescapeJson(token);
|
|
131
|
+
const redacted = redactJson(inner, true);
|
|
132
|
+
if (redacted !== inner) {
|
|
133
|
+
out += text.slice(copied, i) + JSON.stringify(redacted);
|
|
134
|
+
copied = end;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
i = end;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const start = skipSpace(text, colon + 1);
|
|
141
|
+
if (start === text.length || !isSensitive(unescapeJson(token))) {
|
|
142
|
+
i = end;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
out += `${text.slice(copied, start)}"${REDACTED}"`;
|
|
146
|
+
i = copied = endOfValue(text, start);
|
|
147
|
+
}
|
|
148
|
+
return out + text.slice(copied);
|
|
149
|
+
}
|
|
150
|
+
function formKey(raw) {
|
|
151
|
+
return raw.replaceAll("+", " ").replace(/%([0-9a-fA-F]{2})/g, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
152
|
+
}
|
|
153
|
+
function redactForm(text) {
|
|
154
|
+
return text.split("&").map((pair) => {
|
|
155
|
+
const eq = pair.indexOf("=");
|
|
156
|
+
if (eq === -1) return pair;
|
|
157
|
+
const key = pair.slice(0, eq);
|
|
158
|
+
return isSensitive(formKey(key)) ? `${key}=${REDACTED}` : pair;
|
|
159
|
+
}).join("&");
|
|
160
|
+
}
|
|
161
|
+
/** Copies the first {@link MAX_BYTES} bytes of one body and counts every byte seen. */
|
|
162
|
+
var BodyCapture = class {
|
|
163
|
+
/** Bytes seen, including those past the cap. */
|
|
164
|
+
seen = 0;
|
|
165
|
+
#kept = 0;
|
|
166
|
+
#buffer;
|
|
167
|
+
/**
|
|
168
|
+
* Adds one chunk; values other than strings and byte arrays are ignored.
|
|
169
|
+
*
|
|
170
|
+
* @param chunk - `Buffer`, `Uint8Array` or `string`.
|
|
171
|
+
* @param encoding - Encoding of a string chunk; anything else means UTF-8.
|
|
172
|
+
*/
|
|
173
|
+
add(chunk, encoding) {
|
|
174
|
+
if (typeof chunk === "string") {
|
|
175
|
+
const enc = typeof encoding === "string" && Buffer.isEncoding(encoding) ? encoding : "utf8";
|
|
176
|
+
this.seen += Buffer.byteLength(chunk, enc);
|
|
177
|
+
if (this.#kept >= 8192) return;
|
|
178
|
+
const head = /^(hex|base64(url)?)$/i.test(enc) ? chunk : chunk.slice(0, MAX_BYTES - this.#kept + 1);
|
|
179
|
+
this.#copy(Buffer.from(head, enc));
|
|
180
|
+
} else if (chunk instanceof Uint8Array) {
|
|
181
|
+
this.seen += chunk.byteLength;
|
|
182
|
+
this.#copy(chunk);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
#copy(bytes) {
|
|
186
|
+
const n = Math.min(MAX_BYTES - this.#kept, bytes.byteLength);
|
|
187
|
+
if (n <= 0) return;
|
|
188
|
+
const need = this.#kept + n;
|
|
189
|
+
if (this.#buffer === void 0 || this.#buffer.length < need) {
|
|
190
|
+
const size = Math.max(need, 2 * (this.#buffer?.length ?? 0));
|
|
191
|
+
const grown = Buffer.alloc(Math.min(size, MAX_BYTES));
|
|
192
|
+
this.#buffer?.copy(grown, 0, 0, this.#kept);
|
|
193
|
+
this.#buffer = grown;
|
|
194
|
+
}
|
|
195
|
+
this.#buffer.set(bytes.subarray(0, n), this.#kept);
|
|
196
|
+
this.#kept = need;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Builds the size and content attributes for this body.
|
|
200
|
+
*
|
|
201
|
+
* @param direction - Which side of the exchange the body belongs to.
|
|
202
|
+
* @param meta - The body's headers and transfer state.
|
|
203
|
+
* @returns The attributes; empty for an empty body.
|
|
204
|
+
*/
|
|
205
|
+
attributes(direction, meta) {
|
|
206
|
+
const length = headerString(meta.contentLength);
|
|
207
|
+
const size = /^\d+$/.test(length) && (meta.complete || this.seen === 0) ? Number(length) : this.seen;
|
|
208
|
+
if (size === 0) return {};
|
|
209
|
+
const request = direction === "request";
|
|
210
|
+
const attributes = { [request ? ATTR_HTTP_REQUEST_BODY_SIZE : ATTR_HTTP_RESPONSE_BODY_SIZE]: size };
|
|
211
|
+
const coding = headerString(meta.contentEncoding).toLowerCase();
|
|
212
|
+
if (this.#buffer === void 0 || coding !== "" && coding !== "identity") return attributes;
|
|
213
|
+
if (!isTextualUtf8(meta.contentType)) return attributes;
|
|
214
|
+
let text = new TextDecoder().decode(this.#buffer.subarray(0, this.#kept), { stream: this.seen > this.#kept });
|
|
215
|
+
if (headerString(meta.contentType).toLowerCase().startsWith(FORM)) text = redactForm(text);
|
|
216
|
+
attributes[request ? ATTR_HTTP_REQUEST_BODY_CONTENT : ATTR_HTTP_RESPONSE_BODY_CONTENT] = redactJson(text);
|
|
217
|
+
return attributes;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
export { BodyCapture, MAX_BYTES, REDACT_KEYS, headerMap, reportFailure, responseHasNoBody };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const require_env = require("../env.cjs");
|
|
2
|
+
const require_rules = require("./rules.cjs");
|
|
3
|
+
let node_diagnostics_channel = require("node:diagnostics_channel");
|
|
4
|
+
// src/body/undici.ts
|
|
5
|
+
const side = (headers, contentLength) => ({
|
|
6
|
+
capture: new require_rules.BodyCapture(),
|
|
7
|
+
meta: {
|
|
8
|
+
contentType: headers.get("content-type"),
|
|
9
|
+
contentEncoding: headers.get("content-encoding"),
|
|
10
|
+
contentLength
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Tells whether the undici that Node bundles publishes the body diagnostics channels.
|
|
15
|
+
*
|
|
16
|
+
* @returns `true` from undici 7.11, which Node bundles from 24.4.
|
|
17
|
+
*/
|
|
18
|
+
function hasBodyChannels() {
|
|
19
|
+
const [major = 0, minor = 0] = (process.versions["undici"] ?? "0").split(".").map(Number);
|
|
20
|
+
return major > 7 || major === 7 && minor >= 11;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Creates the fetch body capture from the undici body diagnostics channels, which Node bundles
|
|
24
|
+
* from 24.4 (undici 7.11). Call it before `instrumentation-undici` is constructed: that
|
|
25
|
+
* instrumentation ends the span in its own trailers and error subscribers, and subscribers run
|
|
26
|
+
* in subscription order, so ours must come first to write while the span is open.
|
|
27
|
+
*
|
|
28
|
+
* @returns The capture, or `undefined` (with one warning) when this Node has no body channels.
|
|
29
|
+
*/
|
|
30
|
+
function createUndiciBodyCapture() {
|
|
31
|
+
if (!hasBodyChannels()) {
|
|
32
|
+
require_env.warnOnce("undici-bodies", `fetch bodies are captured from Node.js 24.4; ${process.version} has no undici body channels`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const exchanges = /* @__PURE__ */ new WeakMap();
|
|
36
|
+
const listeners = [];
|
|
37
|
+
const listen = (name, handle) => {
|
|
38
|
+
const listener = (message) => {
|
|
39
|
+
try {
|
|
40
|
+
const { request } = message;
|
|
41
|
+
handle(request, message);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
require_rules.reportFailure(err);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
(0, node_diagnostics_channel.subscribe)(name, listener);
|
|
47
|
+
listeners.push([name, listener]);
|
|
48
|
+
};
|
|
49
|
+
const chunk = (direction) => (request, message) => {
|
|
50
|
+
exchanges.get(request)?.[direction]?.capture.add(message.chunk);
|
|
51
|
+
};
|
|
52
|
+
const finish = (cut) => (request) => {
|
|
53
|
+
const ex = exchanges.get(request);
|
|
54
|
+
exchanges.delete(request);
|
|
55
|
+
if (!ex?.span.isRecording()) return;
|
|
56
|
+
for (const direction of ["request", "response"]) {
|
|
57
|
+
const s = ex[direction];
|
|
58
|
+
if (s !== void 0) ex.span.setAttributes(s.capture.attributes(direction, {
|
|
59
|
+
...s.meta,
|
|
60
|
+
complete: !cut
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
listen("undici:request:bodyChunkSent", chunk("request"));
|
|
65
|
+
listen("undici:request:bodyChunkReceived", chunk("response"));
|
|
66
|
+
listen("undici:request:trailers", finish(false));
|
|
67
|
+
listen("undici:request:error", finish(true));
|
|
68
|
+
return {
|
|
69
|
+
requestHook(span, request) {
|
|
70
|
+
if (!span.isRecording()) return;
|
|
71
|
+
exchanges.set(request, {
|
|
72
|
+
span,
|
|
73
|
+
request: side(require_rules.headerMap(request.headers), request.contentLength)
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
responseHook(_span, { request, response }) {
|
|
77
|
+
const ex = exchanges.get(request);
|
|
78
|
+
if (ex === void 0 || require_rules.responseHasNoBody(request.method, response.statusCode)) return;
|
|
79
|
+
const headers = require_rules.headerMap(response.headers);
|
|
80
|
+
ex.response = side(headers, headers.get("content-length"));
|
|
81
|
+
},
|
|
82
|
+
unsubscribe() {
|
|
83
|
+
for (const [name, listener] of listeners) (0, node_diagnostics_channel.unsubscribe)(name, listener);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
exports.createUndiciBodyCapture = createUndiciBodyCapture;
|
|
88
|
+
exports.hasBodyChannels = hasBodyChannels;
|