@okfetch/otel 0.5.0
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 +125 -0
- package/dist/index.cjs +354 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +106 -0
- package/dist/index.d.mts +106 -0
- package/dist/index.mjs +347 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @okfetch/otel
|
|
2
|
+
|
|
3
|
+
`@okfetch/otel` is an [OpenTelemetry](https://opentelemetry.io/) tracing plugin for okfetch request lifecycles.
|
|
4
|
+
|
|
5
|
+
It gives you a ready-made `OkfetchPlugin` that records **one `CLIENT` span per request**, covering every retry attempt, with:
|
|
6
|
+
|
|
7
|
+
- the request method, URL, path, and query string
|
|
8
|
+
- explicitly selected request and response headers, with sensitive values redacted
|
|
9
|
+
- the response status code
|
|
10
|
+
- optional request and response body sizes when they can be observed accurately
|
|
11
|
+
- a `traceparent` header on the outgoing request so downstream services join the trace
|
|
12
|
+
|
|
13
|
+
Request and response bodies are never recorded.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @okfetch/otel @okfetch/fetch @opentelemetry/api
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @okfetch/otel @okfetch/fetch @opentelemetry/api
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
You also need an OpenTelemetry SDK registered as the global tracer provider (for example `@opentelemetry/sdk-node`). Without one, the plugin is a no-op.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { okfetch } from "@okfetch/fetch";
|
|
31
|
+
import { otel } from "@okfetch/otel";
|
|
32
|
+
|
|
33
|
+
const result = await okfetch("https://api.example.com/todos/:id", {
|
|
34
|
+
params: { id: 1 },
|
|
35
|
+
plugins: [otel()],
|
|
36
|
+
query: { include: "owner" },
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Put `otel()` after plugins that add headers if you want those headers captured on the span. Header capture is opt-in, as required by the OpenTelemetry security guidance.
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
`otel(options?)`
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
|
|
48
|
+
- `tracer?: Tracer` - tracer used to start spans. Defaults to `trace.getTracer("@okfetch/otel")` from the global provider.
|
|
49
|
+
- `captureRequestHeaders?: boolean | readonly string[]` - request header names to record as `http.request.header.<name>`. Defaults to none. Pass `true` to explicitly capture every request header.
|
|
50
|
+
- `captureResponseHeaders?: boolean | readonly string[]` - response header names to record as `http.response.header.<name>`. Defaults to none. Pass `true` to explicitly capture every response header.
|
|
51
|
+
- `captureBodySizes?: boolean` - record payload body sizes when Fetch exposes enough information to do so accurately. Defaults to `false`.
|
|
52
|
+
- `knownMethods?: readonly string[]` - case-sensitive methods known to the instrumentation. Replaces `DEFAULT_KNOWN_HTTP_METHODS` entirely.
|
|
53
|
+
- `propagateTraceContext?: boolean` - inject W3C `traceparent` / `tracestate` headers into the request. Defaults to `true`.
|
|
54
|
+
- `redact?: { headers?, queryParams?, values? }` - what to redact. `headers` and `queryParams` match names; `values` matches header and query values regardless of name. Each entry is either an array that **replaces** the defaults, or a function that **receives the defaults** and returns the list to use.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
otel({
|
|
58
|
+
captureRequestHeaders: ["content-type", "x-tenant"],
|
|
59
|
+
captureResponseHeaders: ["content-type", "x-request-id"],
|
|
60
|
+
redact: {
|
|
61
|
+
// extend the defaults
|
|
62
|
+
headers: (defaults) => [...defaults, "x-tenant", /^x-internal-/i],
|
|
63
|
+
// replace the defaults entirely
|
|
64
|
+
queryParams: ["customer_id"],
|
|
65
|
+
// also redact any value that looks like an internal ticket id
|
|
66
|
+
values: (defaults) => [...defaults, /^TKT-/],
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Exports:
|
|
72
|
+
|
|
73
|
+
- `DEFAULT_REDACTED_HEADERS` - `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`, `api-key`, `x-amz-security-token`, `x-amz-credential`, `x-amz-signature`, plus `DEFAULT_REDACTED_NAME_PATTERN`
|
|
74
|
+
- `DEFAULT_KNOWN_HTTP_METHODS` - the RFC 9110 methods plus `PATCH` and `QUERY`
|
|
75
|
+
- `DEFAULT_REDACTED_QUERY_PARAMS` - common credential parameter names such as `token`, `access_token`, `api_key`, `password`, `secret`, `signature`, the OAuth grant parameters `code`, `code_verifier`, `client_assertion`, `assertion`, the AWS SigV4 presigned-URL fields `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Amz-Signature`, plus `DEFAULT_REDACTED_NAME_PATTERN`
|
|
76
|
+
- `DEFAULT_REDACTED_NAME_PATTERN` - a pattern included in both default lists; any name containing `auth`, `bearer`, `cred`, `jwt`, `otp`, `passw`, `private`, `secret`, `session`, `sig`, `token`, or `api-key` is redacted even when not listed explicitly. Replacing a list with an array drops it, so include it yourself if you still want it.
|
|
77
|
+
- `DEFAULT_REDACTED_VALUE_PATTERNS` - patterns applied to every header and query value whatever its name: JWTs (`eyJ...` with three segments) and HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, `Negotiate`, `Token`, `OAuth`, `AWS4-HMAC-SHA256` prefixes)
|
|
78
|
+
- `RedactionMatcher`, `RedactionList`, `RedactionOption`, `ValuePatternList`, `ValuePatternOption` - the types behind the `redact` option
|
|
79
|
+
- `HeaderCaptureOption` - the type accepted by the request and response header capture options
|
|
80
|
+
- `REDACTED_VALUE` - the `REDACTED` placeholder written in place of redacted values
|
|
81
|
+
|
|
82
|
+
Name matching is case-insensitive for both headers and query parameters. Redaction errs on the side of hiding too much: a name such as `X-Session-Id` is redacted because it matches the default pattern, and a JWT sent under a harmless-looking name is redacted because of its value.
|
|
83
|
+
|
|
84
|
+
## What It Records
|
|
85
|
+
|
|
86
|
+
Span name: `{method}`, or `{method} {path template}` when the request uses `params` (for example `GET /todos/:id`).
|
|
87
|
+
|
|
88
|
+
Attributes follow the OpenTelemetry HTTP semantic conventions where one exists:
|
|
89
|
+
|
|
90
|
+
| Attribute | Value |
|
|
91
|
+
| ------------------------------ | ---------------------------------------------------------------------------- |
|
|
92
|
+
| `http.request.method` | Known request method, or `_OTHER` |
|
|
93
|
+
| `http.request.method_original` | Original method when `http.request.method` is `_OTHER` |
|
|
94
|
+
| `http.request.body.size` | Request payload bytes, when enabled and accurately observable |
|
|
95
|
+
| `http.request.header.<name>` | Explicitly selected request header values, with sensitive values redacted |
|
|
96
|
+
| `http.request.resend_count` | Number of retries performed |
|
|
97
|
+
| `http.response.body.size` | Response payload bytes from `Content-Length`, when enabled and applicable |
|
|
98
|
+
| `http.response.header.<name>` | Explicitly selected response header values, with sensitive values redacted |
|
|
99
|
+
| `http.response.status_code` | Status code of the final response |
|
|
100
|
+
| `url.full` | Full URL with redacted query parameters and credentials, fragment dropped |
|
|
101
|
+
| `url.scheme` | URL scheme |
|
|
102
|
+
| `url.path` | URL path |
|
|
103
|
+
| `url.query` | Query string with redacted parameters (omitted when empty) |
|
|
104
|
+
| `url.template` | Path template when `params` are used, without query, fragment or credentials |
|
|
105
|
+
| `server.address` | Hostname |
|
|
106
|
+
| `server.port` | Explicit or scheme-default server port |
|
|
107
|
+
| `error.type` | Status code for API errors, otherwise the okfetch error tag |
|
|
108
|
+
| `okfetch.error.tag` | okfetch error tag (`ApiError`, `FetchError`, ...) |
|
|
109
|
+
| `okfetch.validation.issues` | Formatted schema issues for validation failures |
|
|
110
|
+
|
|
111
|
+
Failure handling:
|
|
112
|
+
|
|
113
|
+
- `ApiError` (non-2xx): span status `ERROR`; `http.response.status_code` carries the reason, so no redundant status description is set
|
|
114
|
+
- `FetchError`, `TimeoutError`, `ParseError`, `PluginError`: span status `ERROR` with the error message, plus an `exception` event via `span.recordException`
|
|
115
|
+
- `ValidationError`: the same failure details, with formatted schema issues added to the status message, exception message and `okfetch.validation.issues`
|
|
116
|
+
|
|
117
|
+
Every retry adds an `okfetch.retry` event carrying the attempt number, the error tag, and the status code when a response was received.
|
|
118
|
+
|
|
119
|
+
### HTTP registry coverage
|
|
120
|
+
|
|
121
|
+
The plugin emits every HTTP registry attribute that applies to a Fetch client and can be observed accurately. `http.connection.state` belongs to connection-pool metrics, and `http.route` belongs to server spans, so neither applies. Fetch does not expose protocol framing or total bytes on the wire, so `http.request.size` and `http.response.size` are intentionally omitted rather than estimated. Response body size is omitted for `HEAD`, `204`, and `304` responses and whenever no valid `Content-Length` is available. Deprecated HTTP attributes are never emitted.
|
|
122
|
+
|
|
123
|
+
## Relationship To `@okfetch/fetch`
|
|
124
|
+
|
|
125
|
+
This package is just a plugin built on top of the public `OkfetchPlugin` interface from `@okfetch/fetch`. It only depends on `@opentelemetry/api`, so it works with any OpenTelemetry SDK setup.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _opentelemetry_api = require("@opentelemetry/api");
|
|
3
|
+
//#region packages/otel/index.ts
|
|
4
|
+
const PLUGIN_NAME = "otel";
|
|
5
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
6
|
+
const TRACER_NAME = "@okfetch/otel";
|
|
7
|
+
/** HTTP methods known by default, as defined by the HTTP semantic conventions. */
|
|
8
|
+
const DEFAULT_KNOWN_HTTP_METHODS = [
|
|
9
|
+
"CONNECT",
|
|
10
|
+
"DELETE",
|
|
11
|
+
"GET",
|
|
12
|
+
"HEAD",
|
|
13
|
+
"OPTIONS",
|
|
14
|
+
"PATCH",
|
|
15
|
+
"POST",
|
|
16
|
+
"PUT",
|
|
17
|
+
"QUERY",
|
|
18
|
+
"TRACE"
|
|
19
|
+
];
|
|
20
|
+
/** Value written in place of redacted headers and query parameters. */
|
|
21
|
+
const REDACTED_VALUE = "REDACTED";
|
|
22
|
+
/**
|
|
23
|
+
* Header and query parameter names matching this pattern are redacted even
|
|
24
|
+
* when they are not listed explicitly, so vendor-specific credential fields
|
|
25
|
+
* such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part
|
|
26
|
+
* of both default lists.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_REDACTED_NAME_PATTERN = /auth|bearer|cred|jwt|otp|passw|private|secret|session|sig|token|api[-_]?key/i;
|
|
29
|
+
/**
|
|
30
|
+
* Header and query parameter values matching one of these patterns are
|
|
31
|
+
* redacted whatever their name, so a credential carried under an arbitrary
|
|
32
|
+
* name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and
|
|
33
|
+
* HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_REDACTED_VALUE_PATTERNS = [/^eyJ[\w-]+\.[\w-]+\.[\w-]*$/, /^(?:Bearer|Basic|Digest|Negotiate|Token|OAuth|AWS4-HMAC-SHA256)\s/i];
|
|
36
|
+
/** Request headers whose values are never recorded on spans. */
|
|
37
|
+
const DEFAULT_REDACTED_HEADERS = [
|
|
38
|
+
"authorization",
|
|
39
|
+
"proxy-authorization",
|
|
40
|
+
"cookie",
|
|
41
|
+
"set-cookie",
|
|
42
|
+
"x-api-key",
|
|
43
|
+
"x-auth-token",
|
|
44
|
+
"api-key",
|
|
45
|
+
"x-amz-security-token",
|
|
46
|
+
"x-amz-credential",
|
|
47
|
+
"x-amz-signature",
|
|
48
|
+
DEFAULT_REDACTED_NAME_PATTERN
|
|
49
|
+
];
|
|
50
|
+
/** Query parameters whose values are never recorded on spans. */
|
|
51
|
+
const DEFAULT_REDACTED_QUERY_PARAMS = [
|
|
52
|
+
"access_token",
|
|
53
|
+
"api_key",
|
|
54
|
+
"apikey",
|
|
55
|
+
"assertion",
|
|
56
|
+
"auth",
|
|
57
|
+
"authorization",
|
|
58
|
+
"awsaccesskeyid",
|
|
59
|
+
"client_assertion",
|
|
60
|
+
"client_secret",
|
|
61
|
+
"code",
|
|
62
|
+
"code_verifier",
|
|
63
|
+
"id_token",
|
|
64
|
+
"key",
|
|
65
|
+
"password",
|
|
66
|
+
"refresh_token",
|
|
67
|
+
"secret",
|
|
68
|
+
"sig",
|
|
69
|
+
"signature",
|
|
70
|
+
"token",
|
|
71
|
+
"x-amz-credential",
|
|
72
|
+
"x-amz-security-token",
|
|
73
|
+
"x-amz-signature",
|
|
74
|
+
"x-goog-signature",
|
|
75
|
+
DEFAULT_REDACTED_NAME_PATTERN
|
|
76
|
+
];
|
|
77
|
+
const stateKey = Symbol.for("@okfetch/otel:state");
|
|
78
|
+
const getState = (carrier) => carrier[stateKey];
|
|
79
|
+
const pathParamPattern = /:[A-Za-z_]\w*(?=[/?#]|$)/;
|
|
80
|
+
const userInfoPattern = /^([a-z][a-z\d+.-]*:\/\/)[^/]*@/i;
|
|
81
|
+
const contentLengthPattern = /^\d+$/;
|
|
82
|
+
/**
|
|
83
|
+
* Reduces a raw request URL to its low-cardinality absolute-path template.
|
|
84
|
+
* Query values, fragments, origins, and embedded credentials are excluded.
|
|
85
|
+
*/
|
|
86
|
+
const toTemplate = (rawUrl) => {
|
|
87
|
+
const [withoutQuery = ""] = rawUrl.split(/[?#]/, 1);
|
|
88
|
+
const withoutUserInfo = withoutQuery.replace(userInfoPattern, "$1");
|
|
89
|
+
if (!pathParamPattern.test(withoutUserInfo)) return;
|
|
90
|
+
try {
|
|
91
|
+
return new URL(withoutUserInfo).pathname;
|
|
92
|
+
} catch {
|
|
93
|
+
return withoutUserInfo;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const headersSetter = { set: (carrier, key, value) => {
|
|
97
|
+
carrier.set(key, value);
|
|
98
|
+
} };
|
|
99
|
+
const resolveList = (defaults, option) => {
|
|
100
|
+
if (option === void 0) return defaults;
|
|
101
|
+
if (Array.isArray(option)) return option;
|
|
102
|
+
return option(defaults);
|
|
103
|
+
};
|
|
104
|
+
const testPattern = (pattern, input) => {
|
|
105
|
+
pattern.lastIndex = 0;
|
|
106
|
+
return pattern.test(input);
|
|
107
|
+
};
|
|
108
|
+
const createValueMatcher = (patterns) => (value) => patterns.some((pattern) => testPattern(pattern, value));
|
|
109
|
+
const createNameMatcher = (list) => {
|
|
110
|
+
const names = /* @__PURE__ */ new Set();
|
|
111
|
+
const patterns = [];
|
|
112
|
+
for (const matcher of list) if (typeof matcher === "string") names.add(matcher.toLowerCase());
|
|
113
|
+
else patterns.push(matcher);
|
|
114
|
+
return (name) => names.has(name.toLowerCase()) || patterns.some((pattern) => testPattern(pattern, name));
|
|
115
|
+
};
|
|
116
|
+
const resolveCapturedHeaders = (option) => option === true ? true : new Set(option === false || option === void 0 ? [] : option.map((name) => name.toLowerCase()));
|
|
117
|
+
const resolveOptions = (options) => ({
|
|
118
|
+
captureBodySizes: options?.captureBodySizes ?? false,
|
|
119
|
+
capturedRequestHeaders: resolveCapturedHeaders(options?.captureRequestHeaders),
|
|
120
|
+
capturedResponseHeaders: resolveCapturedHeaders(options?.captureResponseHeaders),
|
|
121
|
+
propagateTraceContext: options?.propagateTraceContext ?? true,
|
|
122
|
+
isRedactedHeader: createNameMatcher(resolveList(DEFAULT_REDACTED_HEADERS, options?.redact?.headers)),
|
|
123
|
+
isRedactedQueryParam: createNameMatcher(resolveList(DEFAULT_REDACTED_QUERY_PARAMS, options?.redact?.queryParams)),
|
|
124
|
+
isRedactedValue: createValueMatcher(resolveList(DEFAULT_REDACTED_VALUE_PATTERNS, options?.redact?.values)),
|
|
125
|
+
knownMethods: new Set(options?.knownMethods ?? DEFAULT_KNOWN_HTTP_METHODS),
|
|
126
|
+
tracer: options?.tracer ?? _opentelemetry_api.trace.getTracer(TRACER_NAME, PLUGIN_VERSION)
|
|
127
|
+
});
|
|
128
|
+
const getMethodAttributes = (method, knownMethods) => {
|
|
129
|
+
const semanticMethod = knownMethods.has(method) ? method : "_OTHER";
|
|
130
|
+
const attributes = { "http.request.method": semanticMethod };
|
|
131
|
+
if (method !== semanticMethod) attributes["http.request.method_original"] = method;
|
|
132
|
+
return attributes;
|
|
133
|
+
};
|
|
134
|
+
const getContentLength = (headers) => {
|
|
135
|
+
const value = headers.get("content-length")?.trim();
|
|
136
|
+
if (!value || !contentLengthPattern.test(value)) return;
|
|
137
|
+
const size = Number(value);
|
|
138
|
+
return Number.isSafeInteger(size) ? size : void 0;
|
|
139
|
+
};
|
|
140
|
+
const getRequestBodySize = (body, headers) => {
|
|
141
|
+
if (body === void 0) return;
|
|
142
|
+
const contentLength = getContentLength(headers);
|
|
143
|
+
if (contentLength !== void 0) return contentLength;
|
|
144
|
+
if (headers.has("content-encoding")) return;
|
|
145
|
+
if (typeof body === "string") return new TextEncoder().encode(body).byteLength;
|
|
146
|
+
if (body instanceof URLSearchParams) return new TextEncoder().encode(body.toString()).byteLength;
|
|
147
|
+
if (body instanceof Blob) return body.size;
|
|
148
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body.byteLength;
|
|
149
|
+
};
|
|
150
|
+
const shouldCaptureHeader = (capturedHeaders, name) => capturedHeaders === true || capturedHeaders.has(name);
|
|
151
|
+
const captureHeaders = (attributes, prefix, headers, capturedHeaders, options) => {
|
|
152
|
+
for (const [name, value] of headers) {
|
|
153
|
+
if (!shouldCaptureHeader(capturedHeaders, name)) continue;
|
|
154
|
+
const shouldRedact = options.isRedactedHeader(name) || options.isRedactedValue(value);
|
|
155
|
+
attributes[`${prefix}.${name}`] = [shouldRedact ? REDACTED_VALUE : value];
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const getServerPort = (url) => {
|
|
159
|
+
if (url.port) return Number(url.port);
|
|
160
|
+
if (url.protocol === "http:") return 80;
|
|
161
|
+
if (url.protocol === "https:") return 443;
|
|
162
|
+
};
|
|
163
|
+
const redactUrl = (url, options) => {
|
|
164
|
+
const redacted = new URL(url.toString());
|
|
165
|
+
redacted.hash = "";
|
|
166
|
+
if (redacted.username) redacted.username = REDACTED_VALUE;
|
|
167
|
+
if (redacted.password) redacted.password = REDACTED_VALUE;
|
|
168
|
+
for (const name of new Set(redacted.searchParams.keys())) if (options.isRedactedQueryParam(name) || redacted.searchParams.getAll(name).some(options.isRedactedValue)) redacted.searchParams.set(name, REDACTED_VALUE);
|
|
169
|
+
return redacted;
|
|
170
|
+
};
|
|
171
|
+
const buildRequestAttributes = (ctx, template, options) => {
|
|
172
|
+
const url = redactUrl(ctx.url, options);
|
|
173
|
+
const attributes = {
|
|
174
|
+
...getMethodAttributes(ctx.method, options.knownMethods),
|
|
175
|
+
"server.address": url.hostname,
|
|
176
|
+
"url.full": url.toString(),
|
|
177
|
+
"url.path": url.pathname,
|
|
178
|
+
"url.scheme": url.protocol.replace(/:$/, "")
|
|
179
|
+
};
|
|
180
|
+
const serverPort = getServerPort(url);
|
|
181
|
+
if (serverPort !== void 0) attributes["server.port"] = serverPort;
|
|
182
|
+
if (url.search) attributes["url.query"] = url.search.slice(1);
|
|
183
|
+
if (template) attributes["url.template"] = template;
|
|
184
|
+
if (options.captureBodySizes) {
|
|
185
|
+
const bodySize = getRequestBodySize(ctx.body, ctx.headers);
|
|
186
|
+
if (bodySize !== void 0) attributes["http.request.body.size"] = bodySize;
|
|
187
|
+
}
|
|
188
|
+
captureHeaders(attributes, "http.request.header", ctx.headers, options.capturedRequestHeaders, options);
|
|
189
|
+
return attributes;
|
|
190
|
+
};
|
|
191
|
+
const startSpan = (ctx, state, options) => {
|
|
192
|
+
const method = options.knownMethods.has(ctx.method) ? ctx.method : "HTTP";
|
|
193
|
+
const name = state.template ? `${method} ${state.template}` : method;
|
|
194
|
+
return options.tracer.startSpan(name, {
|
|
195
|
+
attributes: buildRequestAttributes(ctx, state.template, options),
|
|
196
|
+
kind: _opentelemetry_api.SpanKind.CLIENT
|
|
197
|
+
}, _opentelemetry_api.context.active());
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Injects trace context headers into a copy of the request headers. A
|
|
201
|
+
* propagator failure is recorded on the span and the request proceeds without
|
|
202
|
+
* propagation headers: telemetry must never fail the request or leave the
|
|
203
|
+
* span open.
|
|
204
|
+
*/
|
|
205
|
+
const injectTraceContext = (ctx, span) => {
|
|
206
|
+
const headers = new Headers(ctx.headers);
|
|
207
|
+
try {
|
|
208
|
+
_opentelemetry_api.propagation.inject(_opentelemetry_api.trace.setSpan(_opentelemetry_api.context.active(), span), headers, headersSetter);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
span.addEvent("okfetch.propagation_failed", { "exception.message": error instanceof Error ? error.message : String(error) });
|
|
211
|
+
return ctx;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
...ctx,
|
|
215
|
+
headers
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
const endSpan = (state) => {
|
|
219
|
+
if (state.ended) return;
|
|
220
|
+
state.ended = true;
|
|
221
|
+
state.span?.end();
|
|
222
|
+
};
|
|
223
|
+
const recordResponse = (span, ctx, response, options) => {
|
|
224
|
+
const attributes = { "http.response.status_code": response.status };
|
|
225
|
+
if (options.captureBodySizes && ctx.method !== "HEAD" && response.status !== 204 && response.status !== 304) {
|
|
226
|
+
const bodySize = getContentLength(response.headers);
|
|
227
|
+
if (bodySize !== void 0) attributes["http.response.body.size"] = bodySize;
|
|
228
|
+
}
|
|
229
|
+
captureHeaders(attributes, "http.response.header", response.headers, options.capturedResponseHeaders, options);
|
|
230
|
+
span.setAttributes(attributes);
|
|
231
|
+
};
|
|
232
|
+
const recordFailure = (span, ctx, response, error, options) => {
|
|
233
|
+
span.setAttribute("okfetch.error.tag", error._tag);
|
|
234
|
+
if (response) recordResponse(span, ctx, response, options);
|
|
235
|
+
if (error._tag === "ApiError") {
|
|
236
|
+
span.setAttributes({
|
|
237
|
+
"error.type": String(error.statusCode),
|
|
238
|
+
"http.response.status_code": error.statusCode
|
|
239
|
+
});
|
|
240
|
+
span.setStatus({ code: _opentelemetry_api.SpanStatusCode.ERROR });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (error._tag === "ValidationError") {
|
|
244
|
+
const issues = error.issues.map((issue) => {
|
|
245
|
+
const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
246
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
247
|
+
});
|
|
248
|
+
const message = issues.length > 0 ? `${error.message}: ${issues.join("; ")}` : error.message;
|
|
249
|
+
span.setAttribute("okfetch.validation.issues", issues);
|
|
250
|
+
span.setAttribute("error.type", error._tag);
|
|
251
|
+
span.recordException({
|
|
252
|
+
message,
|
|
253
|
+
name: error._tag,
|
|
254
|
+
stack: error.stack
|
|
255
|
+
});
|
|
256
|
+
span.setStatus({
|
|
257
|
+
code: _opentelemetry_api.SpanStatusCode.ERROR,
|
|
258
|
+
message
|
|
259
|
+
});
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
span.setAttribute("error.type", error._tag);
|
|
263
|
+
span.recordException(error);
|
|
264
|
+
span.setStatus({
|
|
265
|
+
code: _opentelemetry_api.SpanStatusCode.ERROR,
|
|
266
|
+
message: error.message
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Creates an OpenTelemetry plugin that records a single `CLIENT` span per
|
|
271
|
+
* okfetch request, spanning every retry attempt.
|
|
272
|
+
*
|
|
273
|
+
* The span records HTTP client attributes available through Fetch and marks
|
|
274
|
+
* failures using the OpenTelemetry HTTP span conventions. Request and response
|
|
275
|
+
* bodies are never recorded.
|
|
276
|
+
*/
|
|
277
|
+
const otel = (options) => {
|
|
278
|
+
const resolved = resolveOptions(options);
|
|
279
|
+
const init = ({ options: requestOptions, url }) => {
|
|
280
|
+
const state = {
|
|
281
|
+
ended: false,
|
|
282
|
+
resendCount: 0,
|
|
283
|
+
template: requestOptions.params === void 0 ? void 0 : toTemplate(url)
|
|
284
|
+
};
|
|
285
|
+
return {
|
|
286
|
+
options: {
|
|
287
|
+
...requestOptions,
|
|
288
|
+
[stateKey]: state
|
|
289
|
+
},
|
|
290
|
+
url
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
const onRequest = (ctx) => {
|
|
294
|
+
const state = getState(ctx) ?? {
|
|
295
|
+
ended: false,
|
|
296
|
+
resendCount: 0
|
|
297
|
+
};
|
|
298
|
+
let nextCtx = {
|
|
299
|
+
...ctx,
|
|
300
|
+
[stateKey]: state
|
|
301
|
+
};
|
|
302
|
+
if (state.span) {
|
|
303
|
+
state.resendCount += 1;
|
|
304
|
+
state.span.setAttribute("http.request.resend_count", state.resendCount);
|
|
305
|
+
} else state.span = startSpan(nextCtx, state, resolved);
|
|
306
|
+
if (resolved.propagateTraceContext) nextCtx = {
|
|
307
|
+
...injectTraceContext(nextCtx, state.span),
|
|
308
|
+
[stateKey]: state
|
|
309
|
+
};
|
|
310
|
+
return nextCtx;
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
name: PLUGIN_NAME,
|
|
314
|
+
version: PLUGIN_VERSION,
|
|
315
|
+
init,
|
|
316
|
+
hooks: {
|
|
317
|
+
onRequest,
|
|
318
|
+
onSuccess(ctx, response) {
|
|
319
|
+
const state = getState(ctx);
|
|
320
|
+
if (!state?.span) return;
|
|
321
|
+
recordResponse(state.span, ctx, response, resolved);
|
|
322
|
+
endSpan(state);
|
|
323
|
+
},
|
|
324
|
+
onFail(ctx, response, error) {
|
|
325
|
+
const state = getState(ctx);
|
|
326
|
+
if (!state) return;
|
|
327
|
+
state.span ??= startSpan(ctx, state, resolved);
|
|
328
|
+
recordFailure(state.span, ctx, response, error, resolved);
|
|
329
|
+
endSpan(state);
|
|
330
|
+
},
|
|
331
|
+
onRetry(ctx, response, error, attempt) {
|
|
332
|
+
const state = getState(ctx);
|
|
333
|
+
if (!state?.span) return;
|
|
334
|
+
const attributes = {
|
|
335
|
+
"error.type": error._tag === "ApiError" ? String(error.statusCode) : error._tag,
|
|
336
|
+
"okfetch.error.tag": error._tag,
|
|
337
|
+
"okfetch.retry.attempt": attempt + 1
|
|
338
|
+
};
|
|
339
|
+
if (response) attributes["http.response.status_code"] = response.status;
|
|
340
|
+
state.span.addEvent("okfetch.retry", attributes);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
//#endregion
|
|
346
|
+
exports.DEFAULT_KNOWN_HTTP_METHODS = DEFAULT_KNOWN_HTTP_METHODS;
|
|
347
|
+
exports.DEFAULT_REDACTED_HEADERS = DEFAULT_REDACTED_HEADERS;
|
|
348
|
+
exports.DEFAULT_REDACTED_NAME_PATTERN = DEFAULT_REDACTED_NAME_PATTERN;
|
|
349
|
+
exports.DEFAULT_REDACTED_QUERY_PARAMS = DEFAULT_REDACTED_QUERY_PARAMS;
|
|
350
|
+
exports.DEFAULT_REDACTED_VALUE_PATTERNS = DEFAULT_REDACTED_VALUE_PATTERNS;
|
|
351
|
+
exports.REDACTED_VALUE = REDACTED_VALUE;
|
|
352
|
+
exports.otel = otel;
|
|
353
|
+
|
|
354
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["trace","SpanKind","context","SpanStatusCode"],"sources":["../index.ts"],"sourcesContent":["import type {\n OkfetchError,\n OkfetchOptions,\n OkfetchPlugin,\n OkfetchPluginInitInput,\n OkfetchRequestContext,\n} from \"@okfetch/fetch\";\nimport {\n context,\n propagation,\n SpanKind,\n SpanStatusCode,\n trace,\n} from \"@opentelemetry/api\";\nimport type {\n Attributes,\n Span,\n TextMapSetter,\n Tracer,\n} from \"@opentelemetry/api\";\n\nconst PLUGIN_NAME = \"otel\";\nconst PLUGIN_VERSION = \"0.1.0\";\nconst TRACER_NAME = \"@okfetch/otel\";\n\n/** HTTP methods known by default, as defined by the HTTP semantic conventions. */\nexport const DEFAULT_KNOWN_HTTP_METHODS = [\n \"CONNECT\",\n \"DELETE\",\n \"GET\",\n \"HEAD\",\n \"OPTIONS\",\n \"PATCH\",\n \"POST\",\n \"PUT\",\n \"QUERY\",\n \"TRACE\",\n] as const;\n\n/** Value written in place of redacted headers and query parameters. */\nexport const REDACTED_VALUE = \"REDACTED\";\n\n/** A header or query parameter name, or a pattern tested against names. */\nexport type RedactionMatcher = string | RegExp;\n\n/** A list of names and patterns whose values are redacted. */\nexport type RedactionList = readonly RedactionMatcher[];\n\n/**\n * Configures what gets redacted. An array replaces the defaults entirely; a\n * function receives the defaults and returns the list to use, which makes\n * extending them a one-liner: `(defaults) => [...defaults, \"x-tenant\"]`.\n */\nexport type RedactionOption =\n | RedactionList\n | ((defaults: RedactionList) => RedactionList);\n\n/**\n * Header and query parameter names matching this pattern are redacted even\n * when they are not listed explicitly, so vendor-specific credential fields\n * such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part\n * of both default lists.\n */\nexport const DEFAULT_REDACTED_NAME_PATTERN =\n /auth|bearer|cred|jwt|otp|passw|private|secret|session|sig|token|api[-_]?key/i;\n\n/** A list of patterns tested against header and query parameter values. */\nexport type ValuePatternList = readonly RegExp[];\n\n/**\n * Configures value-based redaction. An array replaces the defaults; a\n * function receives the defaults and returns the list to use.\n */\nexport type ValuePatternOption =\n | ValuePatternList\n | ((defaults: ValuePatternList) => ValuePatternList);\n\n/** Header names to capture, or `true` to explicitly capture every header. */\nexport type HeaderCaptureOption = boolean | readonly string[];\n\n/**\n * Header and query parameter values matching one of these patterns are\n * redacted whatever their name, so a credential carried under an arbitrary\n * name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and\n * HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).\n */\nexport const DEFAULT_REDACTED_VALUE_PATTERNS: ValuePatternList = [\n /^eyJ[\\w-]+\\.[\\w-]+\\.[\\w-]*$/,\n /^(?:Bearer|Basic|Digest|Negotiate|Token|OAuth|AWS4-HMAC-SHA256)\\s/i,\n];\n\n/** Request headers whose values are never recorded on spans. */\nexport const DEFAULT_REDACTED_HEADERS: RedactionList = [\n \"authorization\",\n \"proxy-authorization\",\n \"cookie\",\n \"set-cookie\",\n \"x-api-key\",\n \"x-auth-token\",\n \"api-key\",\n \"x-amz-security-token\",\n \"x-amz-credential\",\n \"x-amz-signature\",\n DEFAULT_REDACTED_NAME_PATTERN,\n];\n\n/** Query parameters whose values are never recorded on spans. */\nexport const DEFAULT_REDACTED_QUERY_PARAMS: RedactionList = [\n \"access_token\",\n \"api_key\",\n \"apikey\",\n \"assertion\",\n \"auth\",\n \"authorization\",\n \"awsaccesskeyid\",\n \"client_assertion\",\n \"client_secret\",\n \"code\",\n \"code_verifier\",\n \"id_token\",\n \"key\",\n \"password\",\n \"refresh_token\",\n \"secret\",\n \"sig\",\n \"signature\",\n \"token\",\n \"x-amz-credential\",\n \"x-amz-security-token\",\n \"x-amz-signature\",\n \"x-goog-signature\",\n DEFAULT_REDACTED_NAME_PATTERN,\n];\n\nexport type OtelOptions = {\n /**\n * Tracer used to start spans. Defaults to a tracer named `@okfetch/otel`\n * obtained from the global tracer provider.\n */\n tracer?: Tracer;\n /**\n * Request headers to record as `http.request.header.<name>` attributes.\n * Sensitive values are always redacted. Defaults to no headers. Pass `true`\n * to explicitly capture every request header.\n */\n captureRequestHeaders?: HeaderCaptureOption;\n /**\n * Response headers to record as `http.response.header.<name>` attributes.\n * Sensitive values are always redacted. Defaults to no headers. Pass `true`\n * to explicitly capture every response header.\n */\n captureResponseHeaders?: HeaderCaptureOption;\n /**\n * Whether to record request and response payload sizes when Fetch exposes\n * enough information to calculate them accurately. Defaults to `false`.\n */\n captureBodySizes?: boolean;\n /**\n * Case-sensitive HTTP methods known to this instrumentation. This is a full\n * replacement for `DEFAULT_KNOWN_HTTP_METHODS`.\n */\n knownMethods?: readonly string[];\n /**\n * Whether W3C trace context headers (`traceparent`, `tracestate`) are\n * injected into the outgoing request. Defaults to `true`.\n */\n propagateTraceContext?: boolean;\n /**\n * What to redact from recorded headers and query parameters. Each entry\n * accepts an array (replaces the defaults) or a function (receives the\n * defaults and returns the list to use). Name matching is case-insensitive.\n */\n redact?: {\n /** Defaults to `DEFAULT_REDACTED_HEADERS`. */\n headers?: RedactionOption;\n /** Defaults to `DEFAULT_REDACTED_QUERY_PARAMS`. */\n queryParams?: RedactionOption;\n /**\n * Patterns matched against header and query parameter values, applied\n * regardless of name. Defaults to `DEFAULT_REDACTED_VALUE_PATTERNS`.\n */\n values?: ValuePatternOption;\n };\n};\n\ntype OtelState = {\n ended: boolean;\n resendCount: number;\n span?: Span;\n template?: string;\n};\n\ntype NameMatcher = (name: string) => boolean;\ntype ValueMatcher = (value: string) => boolean;\n\ntype ResolvedOptions = {\n captureBodySizes: boolean;\n capturedRequestHeaders: true | ReadonlySet<string>;\n capturedResponseHeaders: true | ReadonlySet<string>;\n propagateTraceContext: boolean;\n isRedactedHeader: NameMatcher;\n isRedactedQueryParam: NameMatcher;\n isRedactedValue: ValueMatcher;\n knownMethods: ReadonlySet<string>;\n tracer: Tracer;\n};\n\nconst stateKey = Symbol.for(\"@okfetch/otel:state\");\n\ntype WithState<T> = T & { [stateKey]?: OtelState };\n\nconst getState = (\n carrier: OkfetchRequestContext | OkfetchOptions\n): OtelState | undefined => (carrier as WithState<typeof carrier>)[stateKey];\n\nconst pathParamPattern = /:[A-Za-z_]\\w*(?=[/?#]|$)/;\nconst userInfoPattern = /^([a-z][a-z\\d+.-]*:\\/\\/)[^/]*@/i;\nconst contentLengthPattern = /^\\d+$/;\n\n/**\n * Reduces a raw request URL to its low-cardinality absolute-path template.\n * Query values, fragments, origins, and embedded credentials are excluded.\n */\nconst toTemplate = (rawUrl: string): string | undefined => {\n const [withoutQuery = \"\"] = rawUrl.split(/[?#]/, 1);\n const withoutUserInfo = withoutQuery.replace(userInfoPattern, \"$1\");\n if (!pathParamPattern.test(withoutUserInfo)) {\n return undefined;\n }\n\n try {\n return new URL(withoutUserInfo).pathname;\n } catch {\n return withoutUserInfo;\n }\n};\n\nconst headersSetter: TextMapSetter<Headers> = {\n set: (carrier, key, value) => {\n carrier.set(key, value);\n },\n};\n\nconst resolveList = <T extends readonly unknown[]>(\n defaults: T,\n option: T | ((defaults: T) => T) | undefined\n): T => {\n if (option === undefined) {\n return defaults;\n }\n if (Array.isArray(option)) {\n return option as T;\n }\n\n return (option as (defaults: T) => T)(defaults);\n};\n\nconst testPattern = (pattern: RegExp, input: string): boolean => {\n pattern.lastIndex = 0;\n return pattern.test(input);\n};\n\nconst createValueMatcher =\n (patterns: ValuePatternList): ValueMatcher =>\n (value) =>\n patterns.some((pattern) => testPattern(pattern, value));\n\nconst createNameMatcher = (list: RedactionList): NameMatcher => {\n const names = new Set<string>();\n const patterns: RegExp[] = [];\n\n for (const matcher of list) {\n if (typeof matcher === \"string\") {\n names.add(matcher.toLowerCase());\n } else {\n patterns.push(matcher);\n }\n }\n\n return (name) =>\n names.has(name.toLowerCase()) ||\n patterns.some((pattern) => testPattern(pattern, name));\n};\n\nconst resolveCapturedHeaders = (\n option: HeaderCaptureOption | undefined\n): true | ReadonlySet<string> =>\n option === true\n ? true\n : new Set(\n option === false || option === undefined\n ? []\n : option.map((name) => name.toLowerCase())\n );\n\nconst resolveOptions = (options: OtelOptions | undefined): ResolvedOptions => ({\n captureBodySizes: options?.captureBodySizes ?? false,\n capturedRequestHeaders: resolveCapturedHeaders(\n options?.captureRequestHeaders\n ),\n capturedResponseHeaders: resolveCapturedHeaders(\n options?.captureResponseHeaders\n ),\n propagateTraceContext: options?.propagateTraceContext ?? true,\n isRedactedHeader: createNameMatcher(\n resolveList(DEFAULT_REDACTED_HEADERS, options?.redact?.headers)\n ),\n isRedactedQueryParam: createNameMatcher(\n resolveList(DEFAULT_REDACTED_QUERY_PARAMS, options?.redact?.queryParams)\n ),\n isRedactedValue: createValueMatcher(\n resolveList(DEFAULT_REDACTED_VALUE_PATTERNS, options?.redact?.values)\n ),\n knownMethods: new Set(options?.knownMethods ?? DEFAULT_KNOWN_HTTP_METHODS),\n tracer: options?.tracer ?? trace.getTracer(TRACER_NAME, PLUGIN_VERSION),\n});\n\nconst getMethodAttributes = (\n method: string,\n knownMethods: ReadonlySet<string>\n): Attributes => {\n const semanticMethod = knownMethods.has(method) ? method : \"_OTHER\";\n const attributes: Attributes = {\n \"http.request.method\": semanticMethod,\n };\n\n if (method !== semanticMethod) {\n attributes[\"http.request.method_original\"] = method;\n }\n\n return attributes;\n};\n\nconst getContentLength = (headers: Headers): number | undefined => {\n const value = headers.get(\"content-length\")?.trim();\n if (!value || !contentLengthPattern.test(value)) {\n return undefined;\n }\n\n const size = Number(value);\n return Number.isSafeInteger(size) ? size : undefined;\n};\n\nconst getRequestBodySize = (\n body: OkfetchRequestContext[\"body\"],\n headers: Headers\n): number | undefined => {\n if (body === undefined) {\n return undefined;\n }\n\n const contentLength = getContentLength(headers);\n if (contentLength !== undefined) {\n return contentLength;\n }\n if (headers.has(\"content-encoding\")) {\n return undefined;\n }\n if (typeof body === \"string\") {\n return new TextEncoder().encode(body).byteLength;\n }\n if (body instanceof URLSearchParams) {\n return new TextEncoder().encode(body.toString()).byteLength;\n }\n if (body instanceof Blob) {\n return body.size;\n }\n if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {\n return body.byteLength;\n }\n\n return undefined;\n};\n\nconst shouldCaptureHeader = (\n capturedHeaders: true | ReadonlySet<string>,\n name: string\n): boolean => capturedHeaders === true || capturedHeaders.has(name);\n\nconst captureHeaders = (\n attributes: Attributes,\n prefix: \"http.request.header\" | \"http.response.header\",\n headers: Headers,\n capturedHeaders: true | ReadonlySet<string>,\n options: ResolvedOptions\n): void => {\n for (const [name, value] of headers) {\n if (!shouldCaptureHeader(capturedHeaders, name)) {\n continue;\n }\n\n const shouldRedact =\n options.isRedactedHeader(name) || options.isRedactedValue(value);\n attributes[`${prefix}.${name}`] = [shouldRedact ? REDACTED_VALUE : value];\n }\n};\n\nconst getServerPort = (url: URL): number | undefined => {\n if (url.port) {\n return Number(url.port);\n }\n if (url.protocol === \"http:\") {\n return 80;\n }\n if (url.protocol === \"https:\") {\n return 443;\n }\n\n return undefined;\n};\n\nconst redactUrl = (url: URL, options: ResolvedOptions): URL => {\n const redacted = new URL(url.toString());\n redacted.hash = \"\";\n\n if (redacted.username) {\n redacted.username = REDACTED_VALUE;\n }\n if (redacted.password) {\n redacted.password = REDACTED_VALUE;\n }\n\n for (const name of new Set(redacted.searchParams.keys())) {\n const shouldRedact =\n options.isRedactedQueryParam(name) ||\n redacted.searchParams.getAll(name).some(options.isRedactedValue);\n if (shouldRedact) {\n redacted.searchParams.set(name, REDACTED_VALUE);\n }\n }\n\n return redacted;\n};\n\nconst buildRequestAttributes = (\n ctx: OkfetchRequestContext,\n template: string | undefined,\n options: ResolvedOptions\n): Attributes => {\n const url = redactUrl(ctx.url, options);\n const attributes: Attributes = {\n ...getMethodAttributes(ctx.method, options.knownMethods),\n \"server.address\": url.hostname,\n \"url.full\": url.toString(),\n \"url.path\": url.pathname,\n \"url.scheme\": url.protocol.replace(/:$/, \"\"),\n };\n const serverPort = getServerPort(url);\n if (serverPort !== undefined) {\n attributes[\"server.port\"] = serverPort;\n }\n\n if (url.search) {\n attributes[\"url.query\"] = url.search.slice(1);\n }\n if (template) {\n attributes[\"url.template\"] = template;\n }\n\n if (options.captureBodySizes) {\n const bodySize = getRequestBodySize(ctx.body, ctx.headers);\n if (bodySize !== undefined) {\n attributes[\"http.request.body.size\"] = bodySize;\n }\n }\n captureHeaders(\n attributes,\n \"http.request.header\",\n ctx.headers,\n options.capturedRequestHeaders,\n options\n );\n\n return attributes;\n};\n\nconst startSpan = (\n ctx: OkfetchRequestContext,\n state: OtelState,\n options: ResolvedOptions\n): Span => {\n const method = options.knownMethods.has(ctx.method) ? ctx.method : \"HTTP\";\n const name = state.template ? `${method} ${state.template}` : method;\n\n return options.tracer.startSpan(\n name,\n {\n attributes: buildRequestAttributes(ctx, state.template, options),\n kind: SpanKind.CLIENT,\n },\n context.active()\n );\n};\n\n/**\n * Injects trace context headers into a copy of the request headers. A\n * propagator failure is recorded on the span and the request proceeds without\n * propagation headers: telemetry must never fail the request or leave the\n * span open.\n */\nconst injectTraceContext = (\n ctx: OkfetchRequestContext,\n span: Span\n): OkfetchRequestContext => {\n const headers = new Headers(ctx.headers);\n\n try {\n propagation.inject(\n trace.setSpan(context.active(), span),\n headers,\n headersSetter\n );\n } catch (error) {\n span.addEvent(\"okfetch.propagation_failed\", {\n \"exception.message\":\n error instanceof Error ? error.message : String(error),\n });\n return ctx;\n }\n\n return { ...ctx, headers };\n};\n\nconst endSpan = (state: OtelState): void => {\n if (state.ended) {\n return;\n }\n\n state.ended = true;\n state.span?.end();\n};\n\nconst recordResponse = (\n span: Span,\n ctx: OkfetchRequestContext,\n response: Response,\n options: ResolvedOptions\n): void => {\n const attributes: Attributes = {\n \"http.response.status_code\": response.status,\n };\n\n if (\n options.captureBodySizes &&\n ctx.method !== \"HEAD\" &&\n response.status !== 204 &&\n response.status !== 304\n ) {\n const bodySize = getContentLength(response.headers);\n if (bodySize !== undefined) {\n attributes[\"http.response.body.size\"] = bodySize;\n }\n }\n captureHeaders(\n attributes,\n \"http.response.header\",\n response.headers,\n options.capturedResponseHeaders,\n options\n );\n span.setAttributes(attributes);\n};\n\nconst recordFailure = (\n span: Span,\n ctx: OkfetchRequestContext,\n response: Response | undefined,\n error: OkfetchError<unknown>,\n options: ResolvedOptions\n): void => {\n span.setAttribute(\"okfetch.error.tag\", error._tag);\n\n if (response) {\n recordResponse(span, ctx, response, options);\n }\n\n if (error._tag === \"ApiError\") {\n span.setAttributes({\n \"error.type\": String(error.statusCode),\n \"http.response.status_code\": error.statusCode,\n });\n span.setStatus({ code: SpanStatusCode.ERROR });\n return;\n }\n\n if (error._tag === \"ValidationError\") {\n const issues = error.issues.map((issue) => {\n const path = issue.path\n ?.map((segment) =>\n String(typeof segment === \"object\" ? segment.key : segment)\n )\n .join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n });\n const message =\n issues.length > 0\n ? `${error.message}: ${issues.join(\"; \")}`\n : error.message;\n\n span.setAttribute(\"okfetch.validation.issues\", issues);\n span.setAttribute(\"error.type\", error._tag);\n span.recordException({ message, name: error._tag, stack: error.stack });\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n return;\n }\n\n span.setAttribute(\"error.type\", error._tag);\n span.recordException(error);\n span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });\n};\n\n/**\n * Creates an OpenTelemetry plugin that records a single `CLIENT` span per\n * okfetch request, spanning every retry attempt.\n *\n * The span records HTTP client attributes available through Fetch and marks\n * failures using the OpenTelemetry HTTP span conventions. Request and response\n * bodies are never recorded.\n */\nexport const otel = (options?: OtelOptions): OkfetchPlugin => {\n const resolved = resolveOptions(options);\n\n const init = ({\n options: requestOptions,\n url,\n }: OkfetchPluginInitInput): OkfetchPluginInitInput => {\n const state: OtelState = {\n ended: false,\n resendCount: 0,\n template:\n requestOptions.params === undefined ? undefined : toTemplate(url),\n };\n const nextOptions: WithState<OkfetchOptions> = {\n ...requestOptions,\n [stateKey]: state,\n };\n\n return { options: nextOptions, url };\n };\n\n const onRequest = (ctx: OkfetchRequestContext): OkfetchRequestContext => {\n const state = getState(ctx) ?? { ended: false, resendCount: 0 };\n let nextCtx: WithState<OkfetchRequestContext> = {\n ...ctx,\n [stateKey]: state,\n };\n if (state.span) {\n state.resendCount += 1;\n state.span.setAttribute(\"http.request.resend_count\", state.resendCount);\n } else {\n state.span = startSpan(nextCtx, state, resolved);\n }\n\n if (resolved.propagateTraceContext) {\n nextCtx = {\n ...injectTraceContext(nextCtx, state.span),\n [stateKey]: state,\n };\n }\n\n return nextCtx;\n };\n\n return {\n name: PLUGIN_NAME,\n version: PLUGIN_VERSION,\n init,\n hooks: {\n onRequest,\n onSuccess(ctx, response) {\n const state = getState(ctx);\n if (!state?.span) {\n return;\n }\n\n recordResponse(state.span, ctx, response, resolved);\n endSpan(state);\n },\n onFail(ctx, response, error) {\n const state = getState(ctx);\n if (!state) {\n return;\n }\n\n state.span ??= startSpan(ctx, state, resolved);\n recordFailure(state.span, ctx, response, error, resolved);\n endSpan(state);\n },\n onRetry(ctx, response, error, attempt) {\n const state = getState(ctx);\n if (!state?.span) {\n return;\n }\n\n const attributes: Attributes = {\n \"error.type\":\n error._tag === \"ApiError\" ? String(error.statusCode) : error._tag,\n \"okfetch.error.tag\": error._tag,\n \"okfetch.retry.attempt\": attempt + 1,\n };\n if (response) {\n attributes[\"http.response.status_code\"] = response.status;\n }\n\n state.span.addEvent(\"okfetch.retry\", attributes);\n },\n },\n } satisfies OkfetchPlugin;\n};\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { OkfetchPlugin } from "@okfetch/fetch";
|
|
2
|
+
import { Tracer } from "@opentelemetry/api";
|
|
3
|
+
//#region packages/otel/index.d.ts
|
|
4
|
+
/** HTTP methods known by default, as defined by the HTTP semantic conventions. */
|
|
5
|
+
declare const DEFAULT_KNOWN_HTTP_METHODS: readonly ["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE"];
|
|
6
|
+
/** Value written in place of redacted headers and query parameters. */
|
|
7
|
+
declare const REDACTED_VALUE = "REDACTED";
|
|
8
|
+
/** A header or query parameter name, or a pattern tested against names. */
|
|
9
|
+
type RedactionMatcher = string | RegExp;
|
|
10
|
+
/** A list of names and patterns whose values are redacted. */
|
|
11
|
+
type RedactionList = readonly RedactionMatcher[];
|
|
12
|
+
/**
|
|
13
|
+
* Configures what gets redacted. An array replaces the defaults entirely; a
|
|
14
|
+
* function receives the defaults and returns the list to use, which makes
|
|
15
|
+
* extending them a one-liner: `(defaults) => [...defaults, "x-tenant"]`.
|
|
16
|
+
*/
|
|
17
|
+
type RedactionOption = RedactionList | ((defaults: RedactionList) => RedactionList);
|
|
18
|
+
/**
|
|
19
|
+
* Header and query parameter names matching this pattern are redacted even
|
|
20
|
+
* when they are not listed explicitly, so vendor-specific credential fields
|
|
21
|
+
* such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part
|
|
22
|
+
* of both default lists.
|
|
23
|
+
*/
|
|
24
|
+
declare const DEFAULT_REDACTED_NAME_PATTERN: RegExp;
|
|
25
|
+
/** A list of patterns tested against header and query parameter values. */
|
|
26
|
+
type ValuePatternList = readonly RegExp[];
|
|
27
|
+
/**
|
|
28
|
+
* Configures value-based redaction. An array replaces the defaults; a
|
|
29
|
+
* function receives the defaults and returns the list to use.
|
|
30
|
+
*/
|
|
31
|
+
type ValuePatternOption = ValuePatternList | ((defaults: ValuePatternList) => ValuePatternList);
|
|
32
|
+
/** Header names to capture, or `true` to explicitly capture every header. */
|
|
33
|
+
type HeaderCaptureOption = boolean | readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Header and query parameter values matching one of these patterns are
|
|
36
|
+
* redacted whatever their name, so a credential carried under an arbitrary
|
|
37
|
+
* name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and
|
|
38
|
+
* HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).
|
|
39
|
+
*/
|
|
40
|
+
declare const DEFAULT_REDACTED_VALUE_PATTERNS: ValuePatternList;
|
|
41
|
+
/** Request headers whose values are never recorded on spans. */
|
|
42
|
+
declare const DEFAULT_REDACTED_HEADERS: RedactionList;
|
|
43
|
+
/** Query parameters whose values are never recorded on spans. */
|
|
44
|
+
declare const DEFAULT_REDACTED_QUERY_PARAMS: RedactionList;
|
|
45
|
+
type OtelOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* Tracer used to start spans. Defaults to a tracer named `@okfetch/otel`
|
|
48
|
+
* obtained from the global tracer provider.
|
|
49
|
+
*/
|
|
50
|
+
tracer?: Tracer;
|
|
51
|
+
/**
|
|
52
|
+
* Request headers to record as `http.request.header.<name>` attributes.
|
|
53
|
+
* Sensitive values are always redacted. Defaults to no headers. Pass `true`
|
|
54
|
+
* to explicitly capture every request header.
|
|
55
|
+
*/
|
|
56
|
+
captureRequestHeaders?: HeaderCaptureOption;
|
|
57
|
+
/**
|
|
58
|
+
* Response headers to record as `http.response.header.<name>` attributes.
|
|
59
|
+
* Sensitive values are always redacted. Defaults to no headers. Pass `true`
|
|
60
|
+
* to explicitly capture every response header.
|
|
61
|
+
*/
|
|
62
|
+
captureResponseHeaders?: HeaderCaptureOption;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to record request and response payload sizes when Fetch exposes
|
|
65
|
+
* enough information to calculate them accurately. Defaults to `false`.
|
|
66
|
+
*/
|
|
67
|
+
captureBodySizes?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Case-sensitive HTTP methods known to this instrumentation. This is a full
|
|
70
|
+
* replacement for `DEFAULT_KNOWN_HTTP_METHODS`.
|
|
71
|
+
*/
|
|
72
|
+
knownMethods?: readonly string[];
|
|
73
|
+
/**
|
|
74
|
+
* Whether W3C trace context headers (`traceparent`, `tracestate`) are
|
|
75
|
+
* injected into the outgoing request. Defaults to `true`.
|
|
76
|
+
*/
|
|
77
|
+
propagateTraceContext?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* What to redact from recorded headers and query parameters. Each entry
|
|
80
|
+
* accepts an array (replaces the defaults) or a function (receives the
|
|
81
|
+
* defaults and returns the list to use). Name matching is case-insensitive.
|
|
82
|
+
*/
|
|
83
|
+
redact?: {
|
|
84
|
+
/** Defaults to `DEFAULT_REDACTED_HEADERS`. */
|
|
85
|
+
headers?: RedactionOption;
|
|
86
|
+
/** Defaults to `DEFAULT_REDACTED_QUERY_PARAMS`. */
|
|
87
|
+
queryParams?: RedactionOption;
|
|
88
|
+
/**
|
|
89
|
+
* Patterns matched against header and query parameter values, applied
|
|
90
|
+
* regardless of name. Defaults to `DEFAULT_REDACTED_VALUE_PATTERNS`.
|
|
91
|
+
*/
|
|
92
|
+
values?: ValuePatternOption;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Creates an OpenTelemetry plugin that records a single `CLIENT` span per
|
|
97
|
+
* okfetch request, spanning every retry attempt.
|
|
98
|
+
*
|
|
99
|
+
* The span records HTTP client attributes available through Fetch and marks
|
|
100
|
+
* failures using the OpenTelemetry HTTP span conventions. Request and response
|
|
101
|
+
* bodies are never recorded.
|
|
102
|
+
*/
|
|
103
|
+
declare const otel: (options?: OtelOptions) => OkfetchPlugin;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { DEFAULT_KNOWN_HTTP_METHODS, DEFAULT_REDACTED_HEADERS, DEFAULT_REDACTED_NAME_PATTERN, DEFAULT_REDACTED_QUERY_PARAMS, DEFAULT_REDACTED_VALUE_PATTERNS, HeaderCaptureOption, OtelOptions, REDACTED_VALUE, RedactionList, RedactionMatcher, RedactionOption, ValuePatternList, ValuePatternOption, otel };
|
|
106
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Tracer } from "@opentelemetry/api";
|
|
2
|
+
import { OkfetchPlugin } from "@okfetch/fetch";
|
|
3
|
+
//#region packages/otel/index.d.ts
|
|
4
|
+
/** HTTP methods known by default, as defined by the HTTP semantic conventions. */
|
|
5
|
+
declare const DEFAULT_KNOWN_HTTP_METHODS: readonly ["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE"];
|
|
6
|
+
/** Value written in place of redacted headers and query parameters. */
|
|
7
|
+
declare const REDACTED_VALUE = "REDACTED";
|
|
8
|
+
/** A header or query parameter name, or a pattern tested against names. */
|
|
9
|
+
type RedactionMatcher = string | RegExp;
|
|
10
|
+
/** A list of names and patterns whose values are redacted. */
|
|
11
|
+
type RedactionList = readonly RedactionMatcher[];
|
|
12
|
+
/**
|
|
13
|
+
* Configures what gets redacted. An array replaces the defaults entirely; a
|
|
14
|
+
* function receives the defaults and returns the list to use, which makes
|
|
15
|
+
* extending them a one-liner: `(defaults) => [...defaults, "x-tenant"]`.
|
|
16
|
+
*/
|
|
17
|
+
type RedactionOption = RedactionList | ((defaults: RedactionList) => RedactionList);
|
|
18
|
+
/**
|
|
19
|
+
* Header and query parameter names matching this pattern are redacted even
|
|
20
|
+
* when they are not listed explicitly, so vendor-specific credential fields
|
|
21
|
+
* such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part
|
|
22
|
+
* of both default lists.
|
|
23
|
+
*/
|
|
24
|
+
declare const DEFAULT_REDACTED_NAME_PATTERN: RegExp;
|
|
25
|
+
/** A list of patterns tested against header and query parameter values. */
|
|
26
|
+
type ValuePatternList = readonly RegExp[];
|
|
27
|
+
/**
|
|
28
|
+
* Configures value-based redaction. An array replaces the defaults; a
|
|
29
|
+
* function receives the defaults and returns the list to use.
|
|
30
|
+
*/
|
|
31
|
+
type ValuePatternOption = ValuePatternList | ((defaults: ValuePatternList) => ValuePatternList);
|
|
32
|
+
/** Header names to capture, or `true` to explicitly capture every header. */
|
|
33
|
+
type HeaderCaptureOption = boolean | readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Header and query parameter values matching one of these patterns are
|
|
36
|
+
* redacted whatever their name, so a credential carried under an arbitrary
|
|
37
|
+
* name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and
|
|
38
|
+
* HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).
|
|
39
|
+
*/
|
|
40
|
+
declare const DEFAULT_REDACTED_VALUE_PATTERNS: ValuePatternList;
|
|
41
|
+
/** Request headers whose values are never recorded on spans. */
|
|
42
|
+
declare const DEFAULT_REDACTED_HEADERS: RedactionList;
|
|
43
|
+
/** Query parameters whose values are never recorded on spans. */
|
|
44
|
+
declare const DEFAULT_REDACTED_QUERY_PARAMS: RedactionList;
|
|
45
|
+
type OtelOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* Tracer used to start spans. Defaults to a tracer named `@okfetch/otel`
|
|
48
|
+
* obtained from the global tracer provider.
|
|
49
|
+
*/
|
|
50
|
+
tracer?: Tracer;
|
|
51
|
+
/**
|
|
52
|
+
* Request headers to record as `http.request.header.<name>` attributes.
|
|
53
|
+
* Sensitive values are always redacted. Defaults to no headers. Pass `true`
|
|
54
|
+
* to explicitly capture every request header.
|
|
55
|
+
*/
|
|
56
|
+
captureRequestHeaders?: HeaderCaptureOption;
|
|
57
|
+
/**
|
|
58
|
+
* Response headers to record as `http.response.header.<name>` attributes.
|
|
59
|
+
* Sensitive values are always redacted. Defaults to no headers. Pass `true`
|
|
60
|
+
* to explicitly capture every response header.
|
|
61
|
+
*/
|
|
62
|
+
captureResponseHeaders?: HeaderCaptureOption;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to record request and response payload sizes when Fetch exposes
|
|
65
|
+
* enough information to calculate them accurately. Defaults to `false`.
|
|
66
|
+
*/
|
|
67
|
+
captureBodySizes?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Case-sensitive HTTP methods known to this instrumentation. This is a full
|
|
70
|
+
* replacement for `DEFAULT_KNOWN_HTTP_METHODS`.
|
|
71
|
+
*/
|
|
72
|
+
knownMethods?: readonly string[];
|
|
73
|
+
/**
|
|
74
|
+
* Whether W3C trace context headers (`traceparent`, `tracestate`) are
|
|
75
|
+
* injected into the outgoing request. Defaults to `true`.
|
|
76
|
+
*/
|
|
77
|
+
propagateTraceContext?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* What to redact from recorded headers and query parameters. Each entry
|
|
80
|
+
* accepts an array (replaces the defaults) or a function (receives the
|
|
81
|
+
* defaults and returns the list to use). Name matching is case-insensitive.
|
|
82
|
+
*/
|
|
83
|
+
redact?: {
|
|
84
|
+
/** Defaults to `DEFAULT_REDACTED_HEADERS`. */
|
|
85
|
+
headers?: RedactionOption;
|
|
86
|
+
/** Defaults to `DEFAULT_REDACTED_QUERY_PARAMS`. */
|
|
87
|
+
queryParams?: RedactionOption;
|
|
88
|
+
/**
|
|
89
|
+
* Patterns matched against header and query parameter values, applied
|
|
90
|
+
* regardless of name. Defaults to `DEFAULT_REDACTED_VALUE_PATTERNS`.
|
|
91
|
+
*/
|
|
92
|
+
values?: ValuePatternOption;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Creates an OpenTelemetry plugin that records a single `CLIENT` span per
|
|
97
|
+
* okfetch request, spanning every retry attempt.
|
|
98
|
+
*
|
|
99
|
+
* The span records HTTP client attributes available through Fetch and marks
|
|
100
|
+
* failures using the OpenTelemetry HTTP span conventions. Request and response
|
|
101
|
+
* bodies are never recorded.
|
|
102
|
+
*/
|
|
103
|
+
declare const otel: (options?: OtelOptions) => OkfetchPlugin;
|
|
104
|
+
//#endregion
|
|
105
|
+
export { DEFAULT_KNOWN_HTTP_METHODS, DEFAULT_REDACTED_HEADERS, DEFAULT_REDACTED_NAME_PATTERN, DEFAULT_REDACTED_QUERY_PARAMS, DEFAULT_REDACTED_VALUE_PATTERNS, HeaderCaptureOption, OtelOptions, REDACTED_VALUE, RedactionList, RedactionMatcher, RedactionOption, ValuePatternList, ValuePatternOption, otel };
|
|
106
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
|
|
2
|
+
//#region packages/otel/index.ts
|
|
3
|
+
const PLUGIN_NAME = "otel";
|
|
4
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
5
|
+
const TRACER_NAME = "@okfetch/otel";
|
|
6
|
+
/** HTTP methods known by default, as defined by the HTTP semantic conventions. */
|
|
7
|
+
const DEFAULT_KNOWN_HTTP_METHODS = [
|
|
8
|
+
"CONNECT",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"GET",
|
|
11
|
+
"HEAD",
|
|
12
|
+
"OPTIONS",
|
|
13
|
+
"PATCH",
|
|
14
|
+
"POST",
|
|
15
|
+
"PUT",
|
|
16
|
+
"QUERY",
|
|
17
|
+
"TRACE"
|
|
18
|
+
];
|
|
19
|
+
/** Value written in place of redacted headers and query parameters. */
|
|
20
|
+
const REDACTED_VALUE = "REDACTED";
|
|
21
|
+
/**
|
|
22
|
+
* Header and query parameter names matching this pattern are redacted even
|
|
23
|
+
* when they are not listed explicitly, so vendor-specific credential fields
|
|
24
|
+
* such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part
|
|
25
|
+
* of both default lists.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_REDACTED_NAME_PATTERN = /auth|bearer|cred|jwt|otp|passw|private|secret|session|sig|token|api[-_]?key/i;
|
|
28
|
+
/**
|
|
29
|
+
* Header and query parameter values matching one of these patterns are
|
|
30
|
+
* redacted whatever their name, so a credential carried under an arbitrary
|
|
31
|
+
* name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and
|
|
32
|
+
* HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_REDACTED_VALUE_PATTERNS = [/^eyJ[\w-]+\.[\w-]+\.[\w-]*$/, /^(?:Bearer|Basic|Digest|Negotiate|Token|OAuth|AWS4-HMAC-SHA256)\s/i];
|
|
35
|
+
/** Request headers whose values are never recorded on spans. */
|
|
36
|
+
const DEFAULT_REDACTED_HEADERS = [
|
|
37
|
+
"authorization",
|
|
38
|
+
"proxy-authorization",
|
|
39
|
+
"cookie",
|
|
40
|
+
"set-cookie",
|
|
41
|
+
"x-api-key",
|
|
42
|
+
"x-auth-token",
|
|
43
|
+
"api-key",
|
|
44
|
+
"x-amz-security-token",
|
|
45
|
+
"x-amz-credential",
|
|
46
|
+
"x-amz-signature",
|
|
47
|
+
DEFAULT_REDACTED_NAME_PATTERN
|
|
48
|
+
];
|
|
49
|
+
/** Query parameters whose values are never recorded on spans. */
|
|
50
|
+
const DEFAULT_REDACTED_QUERY_PARAMS = [
|
|
51
|
+
"access_token",
|
|
52
|
+
"api_key",
|
|
53
|
+
"apikey",
|
|
54
|
+
"assertion",
|
|
55
|
+
"auth",
|
|
56
|
+
"authorization",
|
|
57
|
+
"awsaccesskeyid",
|
|
58
|
+
"client_assertion",
|
|
59
|
+
"client_secret",
|
|
60
|
+
"code",
|
|
61
|
+
"code_verifier",
|
|
62
|
+
"id_token",
|
|
63
|
+
"key",
|
|
64
|
+
"password",
|
|
65
|
+
"refresh_token",
|
|
66
|
+
"secret",
|
|
67
|
+
"sig",
|
|
68
|
+
"signature",
|
|
69
|
+
"token",
|
|
70
|
+
"x-amz-credential",
|
|
71
|
+
"x-amz-security-token",
|
|
72
|
+
"x-amz-signature",
|
|
73
|
+
"x-goog-signature",
|
|
74
|
+
DEFAULT_REDACTED_NAME_PATTERN
|
|
75
|
+
];
|
|
76
|
+
const stateKey = Symbol.for("@okfetch/otel:state");
|
|
77
|
+
const getState = (carrier) => carrier[stateKey];
|
|
78
|
+
const pathParamPattern = /:[A-Za-z_]\w*(?=[/?#]|$)/;
|
|
79
|
+
const userInfoPattern = /^([a-z][a-z\d+.-]*:\/\/)[^/]*@/i;
|
|
80
|
+
const contentLengthPattern = /^\d+$/;
|
|
81
|
+
/**
|
|
82
|
+
* Reduces a raw request URL to its low-cardinality absolute-path template.
|
|
83
|
+
* Query values, fragments, origins, and embedded credentials are excluded.
|
|
84
|
+
*/
|
|
85
|
+
const toTemplate = (rawUrl) => {
|
|
86
|
+
const [withoutQuery = ""] = rawUrl.split(/[?#]/, 1);
|
|
87
|
+
const withoutUserInfo = withoutQuery.replace(userInfoPattern, "$1");
|
|
88
|
+
if (!pathParamPattern.test(withoutUserInfo)) return;
|
|
89
|
+
try {
|
|
90
|
+
return new URL(withoutUserInfo).pathname;
|
|
91
|
+
} catch {
|
|
92
|
+
return withoutUserInfo;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const headersSetter = { set: (carrier, key, value) => {
|
|
96
|
+
carrier.set(key, value);
|
|
97
|
+
} };
|
|
98
|
+
const resolveList = (defaults, option) => {
|
|
99
|
+
if (option === void 0) return defaults;
|
|
100
|
+
if (Array.isArray(option)) return option;
|
|
101
|
+
return option(defaults);
|
|
102
|
+
};
|
|
103
|
+
const testPattern = (pattern, input) => {
|
|
104
|
+
pattern.lastIndex = 0;
|
|
105
|
+
return pattern.test(input);
|
|
106
|
+
};
|
|
107
|
+
const createValueMatcher = (patterns) => (value) => patterns.some((pattern) => testPattern(pattern, value));
|
|
108
|
+
const createNameMatcher = (list) => {
|
|
109
|
+
const names = /* @__PURE__ */ new Set();
|
|
110
|
+
const patterns = [];
|
|
111
|
+
for (const matcher of list) if (typeof matcher === "string") names.add(matcher.toLowerCase());
|
|
112
|
+
else patterns.push(matcher);
|
|
113
|
+
return (name) => names.has(name.toLowerCase()) || patterns.some((pattern) => testPattern(pattern, name));
|
|
114
|
+
};
|
|
115
|
+
const resolveCapturedHeaders = (option) => option === true ? true : new Set(option === false || option === void 0 ? [] : option.map((name) => name.toLowerCase()));
|
|
116
|
+
const resolveOptions = (options) => ({
|
|
117
|
+
captureBodySizes: options?.captureBodySizes ?? false,
|
|
118
|
+
capturedRequestHeaders: resolveCapturedHeaders(options?.captureRequestHeaders),
|
|
119
|
+
capturedResponseHeaders: resolveCapturedHeaders(options?.captureResponseHeaders),
|
|
120
|
+
propagateTraceContext: options?.propagateTraceContext ?? true,
|
|
121
|
+
isRedactedHeader: createNameMatcher(resolveList(DEFAULT_REDACTED_HEADERS, options?.redact?.headers)),
|
|
122
|
+
isRedactedQueryParam: createNameMatcher(resolveList(DEFAULT_REDACTED_QUERY_PARAMS, options?.redact?.queryParams)),
|
|
123
|
+
isRedactedValue: createValueMatcher(resolveList(DEFAULT_REDACTED_VALUE_PATTERNS, options?.redact?.values)),
|
|
124
|
+
knownMethods: new Set(options?.knownMethods ?? DEFAULT_KNOWN_HTTP_METHODS),
|
|
125
|
+
tracer: options?.tracer ?? trace.getTracer(TRACER_NAME, PLUGIN_VERSION)
|
|
126
|
+
});
|
|
127
|
+
const getMethodAttributes = (method, knownMethods) => {
|
|
128
|
+
const semanticMethod = knownMethods.has(method) ? method : "_OTHER";
|
|
129
|
+
const attributes = { "http.request.method": semanticMethod };
|
|
130
|
+
if (method !== semanticMethod) attributes["http.request.method_original"] = method;
|
|
131
|
+
return attributes;
|
|
132
|
+
};
|
|
133
|
+
const getContentLength = (headers) => {
|
|
134
|
+
const value = headers.get("content-length")?.trim();
|
|
135
|
+
if (!value || !contentLengthPattern.test(value)) return;
|
|
136
|
+
const size = Number(value);
|
|
137
|
+
return Number.isSafeInteger(size) ? size : void 0;
|
|
138
|
+
};
|
|
139
|
+
const getRequestBodySize = (body, headers) => {
|
|
140
|
+
if (body === void 0) return;
|
|
141
|
+
const contentLength = getContentLength(headers);
|
|
142
|
+
if (contentLength !== void 0) return contentLength;
|
|
143
|
+
if (headers.has("content-encoding")) return;
|
|
144
|
+
if (typeof body === "string") return new TextEncoder().encode(body).byteLength;
|
|
145
|
+
if (body instanceof URLSearchParams) return new TextEncoder().encode(body.toString()).byteLength;
|
|
146
|
+
if (body instanceof Blob) return body.size;
|
|
147
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body.byteLength;
|
|
148
|
+
};
|
|
149
|
+
const shouldCaptureHeader = (capturedHeaders, name) => capturedHeaders === true || capturedHeaders.has(name);
|
|
150
|
+
const captureHeaders = (attributes, prefix, headers, capturedHeaders, options) => {
|
|
151
|
+
for (const [name, value] of headers) {
|
|
152
|
+
if (!shouldCaptureHeader(capturedHeaders, name)) continue;
|
|
153
|
+
const shouldRedact = options.isRedactedHeader(name) || options.isRedactedValue(value);
|
|
154
|
+
attributes[`${prefix}.${name}`] = [shouldRedact ? REDACTED_VALUE : value];
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const getServerPort = (url) => {
|
|
158
|
+
if (url.port) return Number(url.port);
|
|
159
|
+
if (url.protocol === "http:") return 80;
|
|
160
|
+
if (url.protocol === "https:") return 443;
|
|
161
|
+
};
|
|
162
|
+
const redactUrl = (url, options) => {
|
|
163
|
+
const redacted = new URL(url.toString());
|
|
164
|
+
redacted.hash = "";
|
|
165
|
+
if (redacted.username) redacted.username = REDACTED_VALUE;
|
|
166
|
+
if (redacted.password) redacted.password = REDACTED_VALUE;
|
|
167
|
+
for (const name of new Set(redacted.searchParams.keys())) if (options.isRedactedQueryParam(name) || redacted.searchParams.getAll(name).some(options.isRedactedValue)) redacted.searchParams.set(name, REDACTED_VALUE);
|
|
168
|
+
return redacted;
|
|
169
|
+
};
|
|
170
|
+
const buildRequestAttributes = (ctx, template, options) => {
|
|
171
|
+
const url = redactUrl(ctx.url, options);
|
|
172
|
+
const attributes = {
|
|
173
|
+
...getMethodAttributes(ctx.method, options.knownMethods),
|
|
174
|
+
"server.address": url.hostname,
|
|
175
|
+
"url.full": url.toString(),
|
|
176
|
+
"url.path": url.pathname,
|
|
177
|
+
"url.scheme": url.protocol.replace(/:$/, "")
|
|
178
|
+
};
|
|
179
|
+
const serverPort = getServerPort(url);
|
|
180
|
+
if (serverPort !== void 0) attributes["server.port"] = serverPort;
|
|
181
|
+
if (url.search) attributes["url.query"] = url.search.slice(1);
|
|
182
|
+
if (template) attributes["url.template"] = template;
|
|
183
|
+
if (options.captureBodySizes) {
|
|
184
|
+
const bodySize = getRequestBodySize(ctx.body, ctx.headers);
|
|
185
|
+
if (bodySize !== void 0) attributes["http.request.body.size"] = bodySize;
|
|
186
|
+
}
|
|
187
|
+
captureHeaders(attributes, "http.request.header", ctx.headers, options.capturedRequestHeaders, options);
|
|
188
|
+
return attributes;
|
|
189
|
+
};
|
|
190
|
+
const startSpan = (ctx, state, options) => {
|
|
191
|
+
const method = options.knownMethods.has(ctx.method) ? ctx.method : "HTTP";
|
|
192
|
+
const name = state.template ? `${method} ${state.template}` : method;
|
|
193
|
+
return options.tracer.startSpan(name, {
|
|
194
|
+
attributes: buildRequestAttributes(ctx, state.template, options),
|
|
195
|
+
kind: SpanKind.CLIENT
|
|
196
|
+
}, context.active());
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Injects trace context headers into a copy of the request headers. A
|
|
200
|
+
* propagator failure is recorded on the span and the request proceeds without
|
|
201
|
+
* propagation headers: telemetry must never fail the request or leave the
|
|
202
|
+
* span open.
|
|
203
|
+
*/
|
|
204
|
+
const injectTraceContext = (ctx, span) => {
|
|
205
|
+
const headers = new Headers(ctx.headers);
|
|
206
|
+
try {
|
|
207
|
+
propagation.inject(trace.setSpan(context.active(), span), headers, headersSetter);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
span.addEvent("okfetch.propagation_failed", { "exception.message": error instanceof Error ? error.message : String(error) });
|
|
210
|
+
return ctx;
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
...ctx,
|
|
214
|
+
headers
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const endSpan = (state) => {
|
|
218
|
+
if (state.ended) return;
|
|
219
|
+
state.ended = true;
|
|
220
|
+
state.span?.end();
|
|
221
|
+
};
|
|
222
|
+
const recordResponse = (span, ctx, response, options) => {
|
|
223
|
+
const attributes = { "http.response.status_code": response.status };
|
|
224
|
+
if (options.captureBodySizes && ctx.method !== "HEAD" && response.status !== 204 && response.status !== 304) {
|
|
225
|
+
const bodySize = getContentLength(response.headers);
|
|
226
|
+
if (bodySize !== void 0) attributes["http.response.body.size"] = bodySize;
|
|
227
|
+
}
|
|
228
|
+
captureHeaders(attributes, "http.response.header", response.headers, options.capturedResponseHeaders, options);
|
|
229
|
+
span.setAttributes(attributes);
|
|
230
|
+
};
|
|
231
|
+
const recordFailure = (span, ctx, response, error, options) => {
|
|
232
|
+
span.setAttribute("okfetch.error.tag", error._tag);
|
|
233
|
+
if (response) recordResponse(span, ctx, response, options);
|
|
234
|
+
if (error._tag === "ApiError") {
|
|
235
|
+
span.setAttributes({
|
|
236
|
+
"error.type": String(error.statusCode),
|
|
237
|
+
"http.response.status_code": error.statusCode
|
|
238
|
+
});
|
|
239
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (error._tag === "ValidationError") {
|
|
243
|
+
const issues = error.issues.map((issue) => {
|
|
244
|
+
const path = issue.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
|
|
245
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
246
|
+
});
|
|
247
|
+
const message = issues.length > 0 ? `${error.message}: ${issues.join("; ")}` : error.message;
|
|
248
|
+
span.setAttribute("okfetch.validation.issues", issues);
|
|
249
|
+
span.setAttribute("error.type", error._tag);
|
|
250
|
+
span.recordException({
|
|
251
|
+
message,
|
|
252
|
+
name: error._tag,
|
|
253
|
+
stack: error.stack
|
|
254
|
+
});
|
|
255
|
+
span.setStatus({
|
|
256
|
+
code: SpanStatusCode.ERROR,
|
|
257
|
+
message
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
span.setAttribute("error.type", error._tag);
|
|
262
|
+
span.recordException(error);
|
|
263
|
+
span.setStatus({
|
|
264
|
+
code: SpanStatusCode.ERROR,
|
|
265
|
+
message: error.message
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Creates an OpenTelemetry plugin that records a single `CLIENT` span per
|
|
270
|
+
* okfetch request, spanning every retry attempt.
|
|
271
|
+
*
|
|
272
|
+
* The span records HTTP client attributes available through Fetch and marks
|
|
273
|
+
* failures using the OpenTelemetry HTTP span conventions. Request and response
|
|
274
|
+
* bodies are never recorded.
|
|
275
|
+
*/
|
|
276
|
+
const otel = (options) => {
|
|
277
|
+
const resolved = resolveOptions(options);
|
|
278
|
+
const init = ({ options: requestOptions, url }) => {
|
|
279
|
+
const state = {
|
|
280
|
+
ended: false,
|
|
281
|
+
resendCount: 0,
|
|
282
|
+
template: requestOptions.params === void 0 ? void 0 : toTemplate(url)
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
options: {
|
|
286
|
+
...requestOptions,
|
|
287
|
+
[stateKey]: state
|
|
288
|
+
},
|
|
289
|
+
url
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
const onRequest = (ctx) => {
|
|
293
|
+
const state = getState(ctx) ?? {
|
|
294
|
+
ended: false,
|
|
295
|
+
resendCount: 0
|
|
296
|
+
};
|
|
297
|
+
let nextCtx = {
|
|
298
|
+
...ctx,
|
|
299
|
+
[stateKey]: state
|
|
300
|
+
};
|
|
301
|
+
if (state.span) {
|
|
302
|
+
state.resendCount += 1;
|
|
303
|
+
state.span.setAttribute("http.request.resend_count", state.resendCount);
|
|
304
|
+
} else state.span = startSpan(nextCtx, state, resolved);
|
|
305
|
+
if (resolved.propagateTraceContext) nextCtx = {
|
|
306
|
+
...injectTraceContext(nextCtx, state.span),
|
|
307
|
+
[stateKey]: state
|
|
308
|
+
};
|
|
309
|
+
return nextCtx;
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
name: PLUGIN_NAME,
|
|
313
|
+
version: PLUGIN_VERSION,
|
|
314
|
+
init,
|
|
315
|
+
hooks: {
|
|
316
|
+
onRequest,
|
|
317
|
+
onSuccess(ctx, response) {
|
|
318
|
+
const state = getState(ctx);
|
|
319
|
+
if (!state?.span) return;
|
|
320
|
+
recordResponse(state.span, ctx, response, resolved);
|
|
321
|
+
endSpan(state);
|
|
322
|
+
},
|
|
323
|
+
onFail(ctx, response, error) {
|
|
324
|
+
const state = getState(ctx);
|
|
325
|
+
if (!state) return;
|
|
326
|
+
state.span ??= startSpan(ctx, state, resolved);
|
|
327
|
+
recordFailure(state.span, ctx, response, error, resolved);
|
|
328
|
+
endSpan(state);
|
|
329
|
+
},
|
|
330
|
+
onRetry(ctx, response, error, attempt) {
|
|
331
|
+
const state = getState(ctx);
|
|
332
|
+
if (!state?.span) return;
|
|
333
|
+
const attributes = {
|
|
334
|
+
"error.type": error._tag === "ApiError" ? String(error.statusCode) : error._tag,
|
|
335
|
+
"okfetch.error.tag": error._tag,
|
|
336
|
+
"okfetch.retry.attempt": attempt + 1
|
|
337
|
+
};
|
|
338
|
+
if (response) attributes["http.response.status_code"] = response.status;
|
|
339
|
+
state.span.addEvent("okfetch.retry", attributes);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
//#endregion
|
|
345
|
+
export { DEFAULT_KNOWN_HTTP_METHODS, DEFAULT_REDACTED_HEADERS, DEFAULT_REDACTED_NAME_PATTERN, DEFAULT_REDACTED_QUERY_PARAMS, DEFAULT_REDACTED_VALUE_PATTERNS, REDACTED_VALUE, otel };
|
|
346
|
+
|
|
347
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../index.ts"],"sourcesContent":["import type {\n OkfetchError,\n OkfetchOptions,\n OkfetchPlugin,\n OkfetchPluginInitInput,\n OkfetchRequestContext,\n} from \"@okfetch/fetch\";\nimport {\n context,\n propagation,\n SpanKind,\n SpanStatusCode,\n trace,\n} from \"@opentelemetry/api\";\nimport type {\n Attributes,\n Span,\n TextMapSetter,\n Tracer,\n} from \"@opentelemetry/api\";\n\nconst PLUGIN_NAME = \"otel\";\nconst PLUGIN_VERSION = \"0.1.0\";\nconst TRACER_NAME = \"@okfetch/otel\";\n\n/** HTTP methods known by default, as defined by the HTTP semantic conventions. */\nexport const DEFAULT_KNOWN_HTTP_METHODS = [\n \"CONNECT\",\n \"DELETE\",\n \"GET\",\n \"HEAD\",\n \"OPTIONS\",\n \"PATCH\",\n \"POST\",\n \"PUT\",\n \"QUERY\",\n \"TRACE\",\n] as const;\n\n/** Value written in place of redacted headers and query parameters. */\nexport const REDACTED_VALUE = \"REDACTED\";\n\n/** A header or query parameter name, or a pattern tested against names. */\nexport type RedactionMatcher = string | RegExp;\n\n/** A list of names and patterns whose values are redacted. */\nexport type RedactionList = readonly RedactionMatcher[];\n\n/**\n * Configures what gets redacted. An array replaces the defaults entirely; a\n * function receives the defaults and returns the list to use, which makes\n * extending them a one-liner: `(defaults) => [...defaults, \"x-tenant\"]`.\n */\nexport type RedactionOption =\n | RedactionList\n | ((defaults: RedactionList) => RedactionList);\n\n/**\n * Header and query parameter names matching this pattern are redacted even\n * when they are not listed explicitly, so vendor-specific credential fields\n * such as `X-Amz-Security-Token` or `X-Goog-Signature` never leak. It is part\n * of both default lists.\n */\nexport const DEFAULT_REDACTED_NAME_PATTERN =\n /auth|bearer|cred|jwt|otp|passw|private|secret|session|sig|token|api[-_]?key/i;\n\n/** A list of patterns tested against header and query parameter values. */\nexport type ValuePatternList = readonly RegExp[];\n\n/**\n * Configures value-based redaction. An array replaces the defaults; a\n * function receives the defaults and returns the list to use.\n */\nexport type ValuePatternOption =\n | ValuePatternList\n | ((defaults: ValuePatternList) => ValuePatternList);\n\n/** Header names to capture, or `true` to explicitly capture every header. */\nexport type HeaderCaptureOption = boolean | readonly string[];\n\n/**\n * Header and query parameter values matching one of these patterns are\n * redacted whatever their name, so a credential carried under an arbitrary\n * name (`X-JWT`, `blob`, ...) still never reaches telemetry. Covers JWTs and\n * HTTP authentication credentials (`Bearer`, `Basic`, `Digest`, ...).\n */\nexport const DEFAULT_REDACTED_VALUE_PATTERNS: ValuePatternList = [\n /^eyJ[\\w-]+\\.[\\w-]+\\.[\\w-]*$/,\n /^(?:Bearer|Basic|Digest|Negotiate|Token|OAuth|AWS4-HMAC-SHA256)\\s/i,\n];\n\n/** Request headers whose values are never recorded on spans. */\nexport const DEFAULT_REDACTED_HEADERS: RedactionList = [\n \"authorization\",\n \"proxy-authorization\",\n \"cookie\",\n \"set-cookie\",\n \"x-api-key\",\n \"x-auth-token\",\n \"api-key\",\n \"x-amz-security-token\",\n \"x-amz-credential\",\n \"x-amz-signature\",\n DEFAULT_REDACTED_NAME_PATTERN,\n];\n\n/** Query parameters whose values are never recorded on spans. */\nexport const DEFAULT_REDACTED_QUERY_PARAMS: RedactionList = [\n \"access_token\",\n \"api_key\",\n \"apikey\",\n \"assertion\",\n \"auth\",\n \"authorization\",\n \"awsaccesskeyid\",\n \"client_assertion\",\n \"client_secret\",\n \"code\",\n \"code_verifier\",\n \"id_token\",\n \"key\",\n \"password\",\n \"refresh_token\",\n \"secret\",\n \"sig\",\n \"signature\",\n \"token\",\n \"x-amz-credential\",\n \"x-amz-security-token\",\n \"x-amz-signature\",\n \"x-goog-signature\",\n DEFAULT_REDACTED_NAME_PATTERN,\n];\n\nexport type OtelOptions = {\n /**\n * Tracer used to start spans. Defaults to a tracer named `@okfetch/otel`\n * obtained from the global tracer provider.\n */\n tracer?: Tracer;\n /**\n * Request headers to record as `http.request.header.<name>` attributes.\n * Sensitive values are always redacted. Defaults to no headers. Pass `true`\n * to explicitly capture every request header.\n */\n captureRequestHeaders?: HeaderCaptureOption;\n /**\n * Response headers to record as `http.response.header.<name>` attributes.\n * Sensitive values are always redacted. Defaults to no headers. Pass `true`\n * to explicitly capture every response header.\n */\n captureResponseHeaders?: HeaderCaptureOption;\n /**\n * Whether to record request and response payload sizes when Fetch exposes\n * enough information to calculate them accurately. Defaults to `false`.\n */\n captureBodySizes?: boolean;\n /**\n * Case-sensitive HTTP methods known to this instrumentation. This is a full\n * replacement for `DEFAULT_KNOWN_HTTP_METHODS`.\n */\n knownMethods?: readonly string[];\n /**\n * Whether W3C trace context headers (`traceparent`, `tracestate`) are\n * injected into the outgoing request. Defaults to `true`.\n */\n propagateTraceContext?: boolean;\n /**\n * What to redact from recorded headers and query parameters. Each entry\n * accepts an array (replaces the defaults) or a function (receives the\n * defaults and returns the list to use). Name matching is case-insensitive.\n */\n redact?: {\n /** Defaults to `DEFAULT_REDACTED_HEADERS`. */\n headers?: RedactionOption;\n /** Defaults to `DEFAULT_REDACTED_QUERY_PARAMS`. */\n queryParams?: RedactionOption;\n /**\n * Patterns matched against header and query parameter values, applied\n * regardless of name. Defaults to `DEFAULT_REDACTED_VALUE_PATTERNS`.\n */\n values?: ValuePatternOption;\n };\n};\n\ntype OtelState = {\n ended: boolean;\n resendCount: number;\n span?: Span;\n template?: string;\n};\n\ntype NameMatcher = (name: string) => boolean;\ntype ValueMatcher = (value: string) => boolean;\n\ntype ResolvedOptions = {\n captureBodySizes: boolean;\n capturedRequestHeaders: true | ReadonlySet<string>;\n capturedResponseHeaders: true | ReadonlySet<string>;\n propagateTraceContext: boolean;\n isRedactedHeader: NameMatcher;\n isRedactedQueryParam: NameMatcher;\n isRedactedValue: ValueMatcher;\n knownMethods: ReadonlySet<string>;\n tracer: Tracer;\n};\n\nconst stateKey = Symbol.for(\"@okfetch/otel:state\");\n\ntype WithState<T> = T & { [stateKey]?: OtelState };\n\nconst getState = (\n carrier: OkfetchRequestContext | OkfetchOptions\n): OtelState | undefined => (carrier as WithState<typeof carrier>)[stateKey];\n\nconst pathParamPattern = /:[A-Za-z_]\\w*(?=[/?#]|$)/;\nconst userInfoPattern = /^([a-z][a-z\\d+.-]*:\\/\\/)[^/]*@/i;\nconst contentLengthPattern = /^\\d+$/;\n\n/**\n * Reduces a raw request URL to its low-cardinality absolute-path template.\n * Query values, fragments, origins, and embedded credentials are excluded.\n */\nconst toTemplate = (rawUrl: string): string | undefined => {\n const [withoutQuery = \"\"] = rawUrl.split(/[?#]/, 1);\n const withoutUserInfo = withoutQuery.replace(userInfoPattern, \"$1\");\n if (!pathParamPattern.test(withoutUserInfo)) {\n return undefined;\n }\n\n try {\n return new URL(withoutUserInfo).pathname;\n } catch {\n return withoutUserInfo;\n }\n};\n\nconst headersSetter: TextMapSetter<Headers> = {\n set: (carrier, key, value) => {\n carrier.set(key, value);\n },\n};\n\nconst resolveList = <T extends readonly unknown[]>(\n defaults: T,\n option: T | ((defaults: T) => T) | undefined\n): T => {\n if (option === undefined) {\n return defaults;\n }\n if (Array.isArray(option)) {\n return option as T;\n }\n\n return (option as (defaults: T) => T)(defaults);\n};\n\nconst testPattern = (pattern: RegExp, input: string): boolean => {\n pattern.lastIndex = 0;\n return pattern.test(input);\n};\n\nconst createValueMatcher =\n (patterns: ValuePatternList): ValueMatcher =>\n (value) =>\n patterns.some((pattern) => testPattern(pattern, value));\n\nconst createNameMatcher = (list: RedactionList): NameMatcher => {\n const names = new Set<string>();\n const patterns: RegExp[] = [];\n\n for (const matcher of list) {\n if (typeof matcher === \"string\") {\n names.add(matcher.toLowerCase());\n } else {\n patterns.push(matcher);\n }\n }\n\n return (name) =>\n names.has(name.toLowerCase()) ||\n patterns.some((pattern) => testPattern(pattern, name));\n};\n\nconst resolveCapturedHeaders = (\n option: HeaderCaptureOption | undefined\n): true | ReadonlySet<string> =>\n option === true\n ? true\n : new Set(\n option === false || option === undefined\n ? []\n : option.map((name) => name.toLowerCase())\n );\n\nconst resolveOptions = (options: OtelOptions | undefined): ResolvedOptions => ({\n captureBodySizes: options?.captureBodySizes ?? false,\n capturedRequestHeaders: resolveCapturedHeaders(\n options?.captureRequestHeaders\n ),\n capturedResponseHeaders: resolveCapturedHeaders(\n options?.captureResponseHeaders\n ),\n propagateTraceContext: options?.propagateTraceContext ?? true,\n isRedactedHeader: createNameMatcher(\n resolveList(DEFAULT_REDACTED_HEADERS, options?.redact?.headers)\n ),\n isRedactedQueryParam: createNameMatcher(\n resolveList(DEFAULT_REDACTED_QUERY_PARAMS, options?.redact?.queryParams)\n ),\n isRedactedValue: createValueMatcher(\n resolveList(DEFAULT_REDACTED_VALUE_PATTERNS, options?.redact?.values)\n ),\n knownMethods: new Set(options?.knownMethods ?? DEFAULT_KNOWN_HTTP_METHODS),\n tracer: options?.tracer ?? trace.getTracer(TRACER_NAME, PLUGIN_VERSION),\n});\n\nconst getMethodAttributes = (\n method: string,\n knownMethods: ReadonlySet<string>\n): Attributes => {\n const semanticMethod = knownMethods.has(method) ? method : \"_OTHER\";\n const attributes: Attributes = {\n \"http.request.method\": semanticMethod,\n };\n\n if (method !== semanticMethod) {\n attributes[\"http.request.method_original\"] = method;\n }\n\n return attributes;\n};\n\nconst getContentLength = (headers: Headers): number | undefined => {\n const value = headers.get(\"content-length\")?.trim();\n if (!value || !contentLengthPattern.test(value)) {\n return undefined;\n }\n\n const size = Number(value);\n return Number.isSafeInteger(size) ? size : undefined;\n};\n\nconst getRequestBodySize = (\n body: OkfetchRequestContext[\"body\"],\n headers: Headers\n): number | undefined => {\n if (body === undefined) {\n return undefined;\n }\n\n const contentLength = getContentLength(headers);\n if (contentLength !== undefined) {\n return contentLength;\n }\n if (headers.has(\"content-encoding\")) {\n return undefined;\n }\n if (typeof body === \"string\") {\n return new TextEncoder().encode(body).byteLength;\n }\n if (body instanceof URLSearchParams) {\n return new TextEncoder().encode(body.toString()).byteLength;\n }\n if (body instanceof Blob) {\n return body.size;\n }\n if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {\n return body.byteLength;\n }\n\n return undefined;\n};\n\nconst shouldCaptureHeader = (\n capturedHeaders: true | ReadonlySet<string>,\n name: string\n): boolean => capturedHeaders === true || capturedHeaders.has(name);\n\nconst captureHeaders = (\n attributes: Attributes,\n prefix: \"http.request.header\" | \"http.response.header\",\n headers: Headers,\n capturedHeaders: true | ReadonlySet<string>,\n options: ResolvedOptions\n): void => {\n for (const [name, value] of headers) {\n if (!shouldCaptureHeader(capturedHeaders, name)) {\n continue;\n }\n\n const shouldRedact =\n options.isRedactedHeader(name) || options.isRedactedValue(value);\n attributes[`${prefix}.${name}`] = [shouldRedact ? REDACTED_VALUE : value];\n }\n};\n\nconst getServerPort = (url: URL): number | undefined => {\n if (url.port) {\n return Number(url.port);\n }\n if (url.protocol === \"http:\") {\n return 80;\n }\n if (url.protocol === \"https:\") {\n return 443;\n }\n\n return undefined;\n};\n\nconst redactUrl = (url: URL, options: ResolvedOptions): URL => {\n const redacted = new URL(url.toString());\n redacted.hash = \"\";\n\n if (redacted.username) {\n redacted.username = REDACTED_VALUE;\n }\n if (redacted.password) {\n redacted.password = REDACTED_VALUE;\n }\n\n for (const name of new Set(redacted.searchParams.keys())) {\n const shouldRedact =\n options.isRedactedQueryParam(name) ||\n redacted.searchParams.getAll(name).some(options.isRedactedValue);\n if (shouldRedact) {\n redacted.searchParams.set(name, REDACTED_VALUE);\n }\n }\n\n return redacted;\n};\n\nconst buildRequestAttributes = (\n ctx: OkfetchRequestContext,\n template: string | undefined,\n options: ResolvedOptions\n): Attributes => {\n const url = redactUrl(ctx.url, options);\n const attributes: Attributes = {\n ...getMethodAttributes(ctx.method, options.knownMethods),\n \"server.address\": url.hostname,\n \"url.full\": url.toString(),\n \"url.path\": url.pathname,\n \"url.scheme\": url.protocol.replace(/:$/, \"\"),\n };\n const serverPort = getServerPort(url);\n if (serverPort !== undefined) {\n attributes[\"server.port\"] = serverPort;\n }\n\n if (url.search) {\n attributes[\"url.query\"] = url.search.slice(1);\n }\n if (template) {\n attributes[\"url.template\"] = template;\n }\n\n if (options.captureBodySizes) {\n const bodySize = getRequestBodySize(ctx.body, ctx.headers);\n if (bodySize !== undefined) {\n attributes[\"http.request.body.size\"] = bodySize;\n }\n }\n captureHeaders(\n attributes,\n \"http.request.header\",\n ctx.headers,\n options.capturedRequestHeaders,\n options\n );\n\n return attributes;\n};\n\nconst startSpan = (\n ctx: OkfetchRequestContext,\n state: OtelState,\n options: ResolvedOptions\n): Span => {\n const method = options.knownMethods.has(ctx.method) ? ctx.method : \"HTTP\";\n const name = state.template ? `${method} ${state.template}` : method;\n\n return options.tracer.startSpan(\n name,\n {\n attributes: buildRequestAttributes(ctx, state.template, options),\n kind: SpanKind.CLIENT,\n },\n context.active()\n );\n};\n\n/**\n * Injects trace context headers into a copy of the request headers. A\n * propagator failure is recorded on the span and the request proceeds without\n * propagation headers: telemetry must never fail the request or leave the\n * span open.\n */\nconst injectTraceContext = (\n ctx: OkfetchRequestContext,\n span: Span\n): OkfetchRequestContext => {\n const headers = new Headers(ctx.headers);\n\n try {\n propagation.inject(\n trace.setSpan(context.active(), span),\n headers,\n headersSetter\n );\n } catch (error) {\n span.addEvent(\"okfetch.propagation_failed\", {\n \"exception.message\":\n error instanceof Error ? error.message : String(error),\n });\n return ctx;\n }\n\n return { ...ctx, headers };\n};\n\nconst endSpan = (state: OtelState): void => {\n if (state.ended) {\n return;\n }\n\n state.ended = true;\n state.span?.end();\n};\n\nconst recordResponse = (\n span: Span,\n ctx: OkfetchRequestContext,\n response: Response,\n options: ResolvedOptions\n): void => {\n const attributes: Attributes = {\n \"http.response.status_code\": response.status,\n };\n\n if (\n options.captureBodySizes &&\n ctx.method !== \"HEAD\" &&\n response.status !== 204 &&\n response.status !== 304\n ) {\n const bodySize = getContentLength(response.headers);\n if (bodySize !== undefined) {\n attributes[\"http.response.body.size\"] = bodySize;\n }\n }\n captureHeaders(\n attributes,\n \"http.response.header\",\n response.headers,\n options.capturedResponseHeaders,\n options\n );\n span.setAttributes(attributes);\n};\n\nconst recordFailure = (\n span: Span,\n ctx: OkfetchRequestContext,\n response: Response | undefined,\n error: OkfetchError<unknown>,\n options: ResolvedOptions\n): void => {\n span.setAttribute(\"okfetch.error.tag\", error._tag);\n\n if (response) {\n recordResponse(span, ctx, response, options);\n }\n\n if (error._tag === \"ApiError\") {\n span.setAttributes({\n \"error.type\": String(error.statusCode),\n \"http.response.status_code\": error.statusCode,\n });\n span.setStatus({ code: SpanStatusCode.ERROR });\n return;\n }\n\n if (error._tag === \"ValidationError\") {\n const issues = error.issues.map((issue) => {\n const path = issue.path\n ?.map((segment) =>\n String(typeof segment === \"object\" ? segment.key : segment)\n )\n .join(\".\");\n return path ? `${path}: ${issue.message}` : issue.message;\n });\n const message =\n issues.length > 0\n ? `${error.message}: ${issues.join(\"; \")}`\n : error.message;\n\n span.setAttribute(\"okfetch.validation.issues\", issues);\n span.setAttribute(\"error.type\", error._tag);\n span.recordException({ message, name: error._tag, stack: error.stack });\n span.setStatus({ code: SpanStatusCode.ERROR, message });\n return;\n }\n\n span.setAttribute(\"error.type\", error._tag);\n span.recordException(error);\n span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });\n};\n\n/**\n * Creates an OpenTelemetry plugin that records a single `CLIENT` span per\n * okfetch request, spanning every retry attempt.\n *\n * The span records HTTP client attributes available through Fetch and marks\n * failures using the OpenTelemetry HTTP span conventions. Request and response\n * bodies are never recorded.\n */\nexport const otel = (options?: OtelOptions): OkfetchPlugin => {\n const resolved = resolveOptions(options);\n\n const init = ({\n options: requestOptions,\n url,\n }: OkfetchPluginInitInput): OkfetchPluginInitInput => {\n const state: OtelState = {\n ended: false,\n resendCount: 0,\n template:\n requestOptions.params === undefined ? undefined : toTemplate(url),\n };\n const nextOptions: WithState<OkfetchOptions> = {\n ...requestOptions,\n [stateKey]: state,\n };\n\n return { options: nextOptions, url };\n };\n\n const onRequest = (ctx: OkfetchRequestContext): OkfetchRequestContext => {\n const state = getState(ctx) ?? { ended: false, resendCount: 0 };\n let nextCtx: WithState<OkfetchRequestContext> = {\n ...ctx,\n [stateKey]: state,\n };\n if (state.span) {\n state.resendCount += 1;\n state.span.setAttribute(\"http.request.resend_count\", state.resendCount);\n } else {\n state.span = startSpan(nextCtx, state, resolved);\n }\n\n if (resolved.propagateTraceContext) {\n nextCtx = {\n ...injectTraceContext(nextCtx, state.span),\n [stateKey]: state,\n };\n }\n\n return nextCtx;\n };\n\n return {\n name: PLUGIN_NAME,\n version: PLUGIN_VERSION,\n init,\n hooks: {\n onRequest,\n onSuccess(ctx, response) {\n const state = getState(ctx);\n if (!state?.span) {\n return;\n }\n\n recordResponse(state.span, ctx, response, resolved);\n endSpan(state);\n },\n onFail(ctx, response, error) {\n const state = getState(ctx);\n if (!state) {\n return;\n }\n\n state.span ??= startSpan(ctx, state, resolved);\n recordFailure(state.span, ctx, response, error, resolved);\n endSpan(state);\n },\n onRetry(ctx, response, error, attempt) {\n const state = getState(ctx);\n if (!state?.span) {\n return;\n }\n\n const attributes: Attributes = {\n \"error.type\":\n error._tag === \"ApiError\" ? String(error.statusCode) : error._tag,\n \"okfetch.error.tag\": error._tag,\n \"okfetch.retry.attempt\": attempt + 1,\n };\n if (response) {\n attributes[\"http.response.status_code\"] = response.status;\n }\n\n state.span.addEvent(\"okfetch.retry\", attributes);\n },\n },\n } satisfies OkfetchPlugin;\n};\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@okfetch/otel",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "An OpenTelemetry tracing plugin for okfetch request lifecycles.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fetch",
|
|
7
|
+
"opentelemetry",
|
|
8
|
+
"otel",
|
|
9
|
+
"plugin",
|
|
10
|
+
"tracing",
|
|
11
|
+
"typescript"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/aldotestino/okfetch#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/aldotestino/okfetch/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/aldotestino/okfetch.git"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.mjs",
|
|
29
|
+
"types": "./dist/index.d.mts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.mts",
|
|
33
|
+
"import": "./dist/index.mjs",
|
|
34
|
+
"require": "./dist/index.cjs",
|
|
35
|
+
"default": "./dist/index.mjs"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@okfetch/fetch": "^0.5.0",
|
|
43
|
+
"@opentelemetry/api": "^1.9.0",
|
|
44
|
+
"@opentelemetry/context-async-hooks": "^2.0.0",
|
|
45
|
+
"@opentelemetry/core": "^2.0.0",
|
|
46
|
+
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
47
|
+
"@types/bun": "latest",
|
|
48
|
+
"better-result": "^3.0.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@okfetch/fetch": "^0.5.0",
|
|
52
|
+
"@opentelemetry/api": "^1.9.0"
|
|
53
|
+
}
|
|
54
|
+
}
|