@okfetch/otel
One OpenTelemetry CLIENT span per request — semantic-convention attributes, trace propagation, and redaction that errs on the side of hiding.
@okfetch/otel records one CLIENT span per request, covering every retry attempt, and
injects a traceparent header so the service you called joins the same trace.
It only depends on @opentelemetry/api, so it works with whatever SDK you already run.
bun add @okfetch/otel @okfetch/fetch @opentelemetry/apiYou need a registered provider
Without a global tracer provider the plugin is a no-op. Register an SDK once at startup, before the first request.
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
new NodeSDK({
serviceName: "checkout-api",
traceExporter: new OTLPTraceExporter(),
}).start();import { okfetch } from "@okfetch/fetch";
import { otel } from "@okfetch/otel";
await okfetch("/todos/:id", {
baseURL: "https://api.example.com",
params: { id: 1 },
query: { include: "owner" },
plugins: [otel()],
});
// span: GET /todos/:idOrder matters
Put otel() after any plugin that sets headers, or those headers will not exist yet
when the span is built. Header capture is opt-in regardless, per OpenTelemetry's security
guidance.
What the span looks like
The span is named {method}, or {method} {path template} when the request uses params —
so GET /todos/:id groups every id under one operation instead of exploding your cardinality.
Attributes follow the HTTP semantic conventions wherever one exists.
| Attribute | Value |
|---|---|
http.request.method | Known request method, or _OTHER |
http.request.method_original | Original method when http.request.method is _OTHER |
http.request.body.size | Request payload bytes, when enabled and accurately observable |
http.request.header.<name> | Explicitly selected request headers, sensitive values redacted |
http.request.resend_count | Number of retries performed |
http.response.body.size | Response payload bytes from Content-Length, when enabled and applicable |
http.response.header.<name> | Explicitly selected response headers, sensitive values redacted |
http.response.status_code | Status code of the final response |
url.full | Full URL, query redacted, credentials stripped, fragment dropped |
url.scheme | URL scheme |
url.path | URL path |
url.query | Query string with redacted parameters (omitted when empty) |
url.template | Path template when params are used |
server.address | Hostname |
server.port | Explicit or scheme-default port |
error.type | Status code for API errors, otherwise the okfetch error tag |
okfetch.error.tag | okfetch error tag (ApiError, FetchError, …) |
okfetch.validation.issues | Formatted schema issues for validation failures |
Request and response bodies are never recorded.
Failures
ApiError(non-2xx) sets span statusERROR. No status description is added, becausehttp.response.status_codealready carries the reason.FetchError,TimeoutError,ParseError, andPluginErrorset span statusERRORwith the message and record anexceptionevent viaspan.recordException.ValidationErrordoes the same, plus formatted schema issues on the status message, the exception message, andokfetch.validation.issues— including request-side failures that never hit the network.
Retries
Every retry adds an okfetch.retry event with the attempt number, the error tag, and the
status code when a response was received. Five attempts still produce one span, with
http.request.resend_count = 4 and four events on the timeline.
Options
Prop
Type
otel({
captureRequestHeaders: ["content-type", "x-tenant"],
captureResponseHeaders: ["content-type", "x-request-id"],
captureBodySizes: true,
});Redaction
The defaults hide a lot, deliberately. Three independent matchers run:
headers— names such asauthorization,cookie,x-api-key, plus a broad patternqueryParams— names such astoken,access_token,password, the OAuth grant parameters, and the AWS SigV4 presigned-URL fieldsvalues— value shapes, whatever the name: JWTs (eyJ…with three segments) andBearer/Basic/Digest/Negotiate/Token/OAuth/AWS4-HMAC-SHA256prefixes
DEFAULT_REDACTED_NAME_PATTERN sits in both name lists: anything containing auth, bearer,
cred, jwt, otp, passw, private, secret, session, sig, token, or api-key is
redacted even when never listed. Matching is case-insensitive.
So X-Session-Id is redacted because of its name, and a JWT sent as X-Fun-Header is
redacted because of its value. Redacted values are replaced with REDACTED
(exported as REDACTED_VALUE).
Extending vs. replacing
Pass an array to replace a default list, or a function to extend it. The function form is almost always what you want:
otel({
redact: {
// extend: keep every default, add two more
headers: (defaults) => [...defaults, "x-tenant", /^x-internal-/i],
// replace: ONLY customer_id is redacted from now on
queryParams: ["customer_id"],
// extend value patterns with an internal ticket format
values: (defaults) => [...defaults, /^TKT-/],
},
});Replacing a list with an array drops DEFAULT_REDACTED_NAME_PATTERN along with it. If you
still want the catch-all, include it yourself:
queryParams: [DEFAULT_REDACTED_NAME_PATTERN, "customer_id"].
What is intentionally missing
The plugin emits every HTTP registry attribute that applies to a Fetch client and can be observed accurately — nothing is estimated.
http.routeis a server-span attribute;url.templateis the client-side equivalent.http.connection.statebelongs to connection-pool metrics, which Fetch does not expose.http.request.sizeandhttp.response.sizecount bytes on the wire, including framing, which Fetch also does not expose.- Response body size is omitted for
HEAD,204, and304, and whenever there is no validContent-Length. - Deprecated HTTP attributes are never emitted.
Exports
Beyond otel() itself, the defaults are exported so you can extend them explicitly:
DEFAULT_REDACTED_HEADERS,DEFAULT_REDACTED_QUERY_PARAMS,DEFAULT_REDACTED_VALUE_PATTERNS,DEFAULT_REDACTED_NAME_PATTERNDEFAULT_KNOWN_HTTP_METHODS— RFC 9110 methods plusPATCHandQUERYREDACTED_VALUE— the placeholder written in place of a redacted value- Types:
RedactionMatcher,RedactionList,RedactionOption,ValuePatternList,ValuePatternOption,HeaderCaptureOption
Together with logging
Tracing and logging answer different questions, and the plugins are independent — register
both, with otel() last so it sees the finished request:
export const api = createApi({
baseURL,
endpoints,
plugins: [logger(), otel({ captureResponseHeaders: ["x-request-id"] })],
});