Skip to content

Commit 20d91d3

Browse files
feat(fetch): Add redactUrl option and sanitizeUrl helper
Allows callers to rewrite `url.full` before it is recorded as a span attribute, stripping secrets from query strings or paths while keeping the span. Unlike `ignore`, the request still goes through. `sanitizeUrl()` follows OTel URL semconv: sensitive query-parameter values and `user:pass@` credentials are replaced with `REDACTED`, with parameter keys preserved. A built-in default list covers common AWS/GCP signing parameters. On Node, requesting `redactUrl` forces the `globalThis.fetch` wrap instead of the native undici instrumentation, which has no hook to rewrite `url.full`.
1 parent 34afd22 commit 20d91d3

14 files changed

Lines changed: 322 additions & 28 deletions

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,18 @@ Options (`instrumentFetch`):
142142
messages kept on Node (see caveats).
143143
- **`ignore`:** `instrumentFetch: { ignore: (url) => url.includes("/healthz") }`. Your own OTLP
144144
endpoint is always excluded automatically, so the exporter never traces itself.
145+
- **`redactUrl`:** `instrumentFetch: { redactUrl: (url) => sanitizeUrl(url, { params: ["token"] }) }`
146+
rewrites the URL stored as `url.full`, so you keep the span but drop secrets from the query string or
147+
path. On Node it forces the `globalThis.fetch` wrap (undici can't rewrite `url.full`).
145148
146149
Caveats:
147150
148151
- **Telemetry differs by runtime under `"auto"`.** undici (Node) emits richer attributes and follows
149152
HTTP semconv for span status — a 2xx client span is left `UNSET`, and only `5xx`/network failures are
150153
marked `ERROR`; the Bun wrap marks all `4xx`/`5xx` as `ERROR`. The Bun wrap also scrubs PII from the
151154
error message attached to span status — **undici does not**. Use `mode: "global"` for parity.
152-
- **`url.full` includes the query string** on both. If your URLs carry secrets there, use `ignore` or
153-
redact upstream.
155+
- **`url.full` includes the query string** on both. If your URLs carry secrets there, strip them with
156+
`redactUrl` (keeps the span) or drop the request with `ignore`.
154157
- **Native fetch tracing needs Node ≥ 20.6** (the undici instrumentation's floor); older 20.x falls
155158
back to the global wrap.
156159
@@ -180,7 +183,8 @@ const client = new OpenAI({
180183
- Returns a fetch function directly — there's no global lifecycle, so no `unpatch()` handle.
181184
- Idempotent: passing an already-instrumented fetch returns it unchanged.
182185
- `options`: `ignore: (url) => boolean` skips spans for some URLs; `attributes` merges static attributes
183-
into every span (the practical way to tell different SDKs' spans apart).
186+
into every span (the practical way to tell different SDKs' spans apart); `redactUrl: (url) => string`
187+
rewrites `url.full` to strip secrets (pair with `sanitizeUrl`).
184188
- Always uses the wrapper technique, so it behaves identically on Bun and Node (the native undici
185189
instrumentation can't target a single instance).
186190

docs/configuration.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,20 @@ setupOtel({
204204
});
205205
```
206206

207+
To keep the span but strip secrets from the URL, use `redactUrl` with the `sanitizeUrl()` helper:
208+
209+
```ts
210+
import { sanitizeUrl, setupOtel } from "@photon-ai/otel";
211+
212+
setupOtel({
213+
serviceName: "orders-api",
214+
endpoint: "http://localhost:4318",
215+
instrumentFetch: {
216+
redactUrl: (url) => sanitizeUrl(url, { params: ["token", "api_key"] }),
217+
},
218+
});
219+
```
220+
207221
On Node, `setupOtel()` uses the native `@opentelemetry/instrumentation-undici` by default; on Bun it wraps `globalThis.fetch`. Force the wrap on both runtimes with `mode: "global"`:
208222

209223
```ts
@@ -236,4 +250,4 @@ The package always excludes its own OTLP trace and log exporter endpoints from f
236250
- Set `serviceVersion` from your release artifact when possible.
237251
- Prefer stable resource attributes over high-cardinality request values.
238252
- Use `LOG_LEVEL=debug` temporarily when debugging production incidents, then return to `info` or higher.
239-
- Avoid putting secrets in URL query strings because fetch spans include `url.full`.
253+
- Avoid putting secrets in URL query strings (fetch spans include `url.full`); when unavoidable, strip them with the `redactUrl` option and the `sanitizeUrl()` helper.

docs/guides/fetch-instrumentation.mdx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,25 @@ When a URL is ignored:
107107
- no span is emitted
108108
- trace headers are not injected by this wrapper
109109

110-
Use this for health checks, high-volume polling, or URLs with secrets in the query string.
110+
Use this for health checks or high-volume polling. To keep the span but hide a secret in the URL, reach for `redactUrl` instead (below).
111+
112+
## Redacting URLs
113+
114+
`ignore` drops the whole span. When you want to keep the span but strip a secret from the URL, use `redactUrl` — it rewrites the value stored as `url.full` while leaving `server.address` / `server.port` (derived from the original URL) and the rest of the span intact.
115+
116+
```ts
117+
import { instrumentFetch, sanitizeUrl } from "@photon-ai/otel";
118+
119+
instrumentFetch({
120+
redactUrl: (url) => sanitizeUrl(url, { params: ["token", "api_key"] }),
121+
});
122+
```
123+
124+
`sanitizeUrl()` follows the OpenTelemetry URL semantic conventions: it replaces sensitive query-parameter values and `user:pass@` credentials with the literal `REDACTED`, keeping the parameter key (`token=REDACTED`). It also redacts a built-in default list (`X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`, `sig`, `X-Goog-Signature`). Pass your own names in `params`, or write any `(url) => string` function for full control — including stripping tokens from path segments.
125+
126+
The redactor only changes what telemetry records; the real request still uses the original URL.
127+
128+
On Node, setting `redactUrl` forces the `globalThis.fetch` wrap instead of the native undici instrumentation (which has no hook to rewrite `url.full`), trading undici's richer attributes for the redaction — the same tradeoff as static `attributes`.
111129

112130
## Automatic OTLP endpoint exclusion
113131

@@ -172,14 +190,14 @@ Native fetch tracing requires Node 20.6 or newer (the undici instrumentation's f
172190

173191
Fetch spans include `url.full`, which includes the query string.
174192

175-
Do not put secrets, tokens, emails, or phone numbers in query strings. If you must call an endpoint with sensitive query parameters, use `ignore` to skip that URL or sanitize upstream.
193+
Prefer keeping secrets, tokens, emails, and phone numbers out of query strings. When you can't, use [`redactUrl`](#redacting-urls) to strip them while keeping the span, or `ignore` to skip the URL entirely.
176194

177-
Thrown fetch error messages are sanitized before being used as span status messages by the `globalThis.fetch` wrap (Bun, or `mode: "global"`). The native undici instrumentation on Node does not scrub status messages — use `mode: "global"` if you need that scrubbing on Node. URL attributes are not sanitized on either path.
195+
Thrown fetch error messages are sanitized before being used as span status messages by the `globalThis.fetch` wrap (Bun, or `mode: "global"`). The native undici instrumentation on Node does not scrub status messages — use `mode: "global"` if you need that scrubbing on Node. Unless you set `redactUrl`, URL attributes are recorded as-is on both paths.
178196

179197
## Best practices
180198

181199
- Prefer automatic setup-managed instrumentation for services.
182-
- Use `ignore` for health checks and sensitive URLs.
183-
- Keep secrets out of query strings.
200+
- Use `ignore` for health checks; use `redactUrl` to strip secrets from traced URLs.
201+
- Keep secrets out of query strings where you can.
184202
- Call `shutdown()` or `unpatch()` in tests to restore global fetch.
185203
- Install setup before code starts making outbound requests.

docs/guides/pii-scrubbing.mdx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ title: "PII scrubbing"
33
description: "Use the sanitize helpers and understand where automatic scrubbing does and does not happen."
44
---
55

6-
The package exports three helpers for masking common personally identifiable information:
6+
The package exports helpers for masking common personally identifiable information and URL secrets:
77

88
- `sanitizeEmail(input)`
99
- `sanitizePhone(input)`
1010
- `sanitizeErrorMessage(input)`
11+
- `sanitizeUrl(input, options?)`
1112

1213
They are intentionally small and predictable. They are not a full data-loss-prevention system.
1314

@@ -79,7 +80,7 @@ The package does not sanitize:
7980
- log attributes
8081
- logger exception attributes
8182
- span attributes
82-
- fetch `url.full`
83+
- fetch `url.full` (opt in with the `redactUrl` option — see [URL safety](#url-safety))
8384
- response bodies
8485
- request bodies
8586
- headers
@@ -123,21 +124,33 @@ Prefer stable internal identifiers over emails or phone numbers.
123124

124125
## URL safety
125126

126-
Fetch spans include `url.full`, including query parameters.
127+
Fetch spans include `url.full`, including query parameters. When a URL carries a token, API key, or other secret, redact it with the `redactUrl` fetch option so the span keeps everything except the secret. The `sanitizeUrl()` helper implements semantic-convention redaction — sensitive query-parameter values and `user:pass@` credentials are replaced with the literal `REDACTED`, with the key preserved:
127128

128-
Avoid URLs like:
129+
```ts
130+
import { sanitizeUrl, setupOtel } from "@photon-ai/otel";
129131

130-
```txt
131-
https://api.example.com/search?email=foo.bar@example.com
132+
setupOtel({
133+
serviceName: "orders-api",
134+
endpoint: "http://localhost:4318",
135+
instrumentFetch: {
136+
redactUrl: (url) => sanitizeUrl(url, { params: ["token", "api_key"] }),
137+
},
138+
});
132139
```
133140

134-
Prefer:
141+
Or per SDK, without touching the global fetch:
135142

136-
```txt
137-
https://api.example.com/search
143+
```ts
144+
import { createInstrumentedFetch, sanitizeUrl } from "@photon-ai/otel";
145+
146+
const fetch = createInstrumentedFetch(undefined, {
147+
redactUrl: (url) => sanitizeUrl(url),
148+
});
138149
```
139150

140-
with the sensitive value in a request body or header that your telemetry pipeline does not record. If you cannot avoid sensitive query strings, configure fetch instrumentation to ignore those URLs.
151+
`redactUrl` keeps the span — unlike `ignore`, which drops it entirely. On Node it forces the `globalThis.fetch` wrap, because the native undici instrumentation has no hook to rewrite `url.full`.
152+
153+
Even with redaction available, prefer keeping secrets out of URLs in the first place — put the sensitive value in a request body or header your telemetry pipeline does not record. Reserve `ignore` for URLs you do not want traced at all.
141154

142155
## Best practices
143156

docs/reference/api.mdx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
sanitizeEmail,
1616
sanitizeErrorMessage,
1717
sanitizePhone,
18+
sanitizeUrl,
1819
setLogLevel,
1920
setupOtel,
2021
withSpan,
@@ -185,11 +186,14 @@ function instrumentFetch(options?: InstrumentFetchOptions): FetchInstrumentation
185186
interface FetchSpanOptions {
186187
ignore?: (url: string) => boolean;
187188
attributes?: Attributes;
189+
redactUrl?: (url: string) => string;
188190
}
189191
```
190192

191193
`ignore` skips spans for matching URLs. `attributes` (the OpenTelemetry `Attributes` type) merges static attributes into every span this instrumentation produces — useful for tagging an SDK's traffic, such as `{ "peer.service": "openai" }`.
192194

195+
`redactUrl` rewrites the URL before it is stored as `url.full`, so you can strip secrets from the query string or path while keeping the span (unlike `ignore`, which drops the span entirely). `server.address` / `server.port` are still derived from the original URL. Pair it with [`sanitizeUrl`](#sanitizeurl-url-options). On Node, setting `redactUrl` forces the `globalThis.fetch` wrap, because the native undici instrumentation has no hook to rewrite `url.full`.
196+
193197
### `InstrumentFetchOptions`
194198

195199
```ts
@@ -237,6 +241,7 @@ function createInstrumentedFetch(
237241

238242
- Wraps the given fetch so each non-ignored request creates a client span with HTTP attributes and W3C trace-context injection.
239243
- Merges `options.attributes` into every span.
244+
- Applies `options.redactUrl` to the stored `url.full`, if provided.
240245
- Returns the fetch function directly — there is no global lifecycle and no `unpatch()`.
241246
- Is idempotent: passing an already-instrumented fetch returns it unchanged.
242247
- Always uses the wrapper technique, so it works identically on Bun and Node.
@@ -288,6 +293,43 @@ sanitizeErrorMessage("contact foo.bar@example.com or +13315553374");
288293
// "contact fo***@e***.com or +133xxxx3374"
289294
```
290295

296+
## `sanitizeUrl(url, options)`
297+
298+
Redacts secrets from a URL before it is recorded as a span attribute, following the OpenTelemetry URL semantic conventions: sensitive query-parameter values and `user:pass@` credentials are replaced with the literal `REDACTED`, with the key preserved (`sig=REDACTED`). Non-sensitive parameters and the path are left intact.
299+
300+
```ts
301+
function sanitizeUrl(url: string, options?: SanitizeUrlOptions): string;
302+
303+
interface SanitizeUrlOptions {
304+
params?: string[];
305+
redactDefaults?: boolean;
306+
}
307+
```
308+
309+
- `params` — additional query-parameter names to redact, on top of the built-in list. Case-sensitive, matching the semantic conventions.
310+
- `redactDefaults` — redact the built-in semconv list (`X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`, `sig`, `X-Goog-Signature`) and `user:pass@` credentials. Defaults to `true`.
311+
312+
Unparseable input — and input with nothing to redact — is returned unchanged.
313+
314+
It is designed to pair with the `redactUrl` fetch option:
315+
316+
```ts
317+
import { createInstrumentedFetch, sanitizeUrl } from "@photon-ai/otel";
318+
319+
const fetch = createInstrumentedFetch(undefined, {
320+
redactUrl: (url) => sanitizeUrl(url, { params: ["token", "api_key"] }),
321+
});
322+
```
323+
324+
Example:
325+
326+
```ts
327+
sanitizeUrl("https://api.example.com/v1?token=secret&page=2", {
328+
params: ["token"],
329+
});
330+
// "https://api.example.com/v1?token=REDACTED&page=2"
331+
```
332+
291333
## `PHOTON_OTEL_VERSION`
292334

293335
Package version constant.

docs/reference/testing.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,4 @@ Then verify:
153153
| Fetch spans missing | no traces endpoint or `instrumentFetch: false` | configure trace endpoint or force `instrumentFetch: true` |
154154
| Logs not correlated with traces | setup ran too late or context was replaced | call `setupOtel()` before work begins |
155155
| Process exits before telemetry appears | batch processors did not flush | await `shutdown()` |
156-
| Sensitive query values visible | fetch spans record `url.full` | remove sensitive query strings or ignore those URLs |
156+
| Sensitive query values visible | fetch spans record `url.full` | strip them with the `redactUrl` option (`sanitizeUrl()` helper), or `ignore` the URL |

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ export {
1515
setLogLevel,
1616
} from "./logger";
1717
export {
18+
type SanitizeUrlOptions,
1819
sanitizeEmail,
1920
sanitizeErrorMessage,
2021
sanitizePhone,
22+
sanitizeUrl,
2123
} from "./sanitize";
2224
export {
2325
isOtelActive,

src/instrument-fetch-native.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,14 @@ export function instrumentFetchNative(
8181
options: InstrumentFetchOptions | undefined,
8282
requireFn: RequireFn
8383
): FetchInstrumentation | undefined {
84-
// The undici instrumentation has no hook to stamp caller-supplied static
85-
// attributes onto every span, so when they're requested, decline the native
86-
// path and let the caller fall back to the globalThis.fetch wrap (which does
87-
// apply them).
88-
if (options?.attributes && Object.keys(options.attributes).length > 0) {
84+
// The undici instrumentation exposes no hook to stamp caller-supplied static
85+
// attributes on every span, nor to rewrite `url.full` for redaction. When
86+
// either is requested, decline the native path and let the caller fall back
87+
// to the globalThis.fetch wrap (which applies both).
88+
const hasStaticAttributes =
89+
options?.attributes !== undefined &&
90+
Object.keys(options.attributes).length > 0;
91+
if (hasStaticAttributes || options?.redactUrl !== undefined) {
8992
return;
9093
}
9194

src/instrument-fetch.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ export interface FetchSpanOptions {
3131
* query string. The request is still performed — only the span is skipped.
3232
*/
3333
ignore?: (url: string) => boolean;
34+
/**
35+
* Rewrite the request URL before it is stored as `url.full`. Use this to
36+
* strip tokens/secrets from the query string or path while still keeping the
37+
* span (unlike `ignore`, which drops the span entirely). Receives the
38+
* absolute URL and returns the value to record; `server.address` /
39+
* `server.port` are still derived from the ORIGINAL URL, so an aggressive
40+
* redactor can't break host resolution. Pair with the `sanitizeUrl()` helper
41+
* for semconv-style query-parameter redaction.
42+
*
43+
* On Node, requesting `redactUrl` forces the `globalThis.fetch` wrap instead
44+
* of the native undici instrumentation (which has no hook to rewrite
45+
* `url.full`), trading undici's richer attributes for the redaction.
46+
*/
47+
redactUrl?: (url: string) => string;
3448
}
3549

3650
export interface InstrumentFetchOptions extends FetchSpanOptions {
@@ -126,10 +140,16 @@ function toAttributes(attrs: Attributes): Attributes {
126140
return out;
127141
}
128142

129-
function fetchAttributes(method: string, url: string): Attributes {
143+
function fetchAttributes(
144+
method: string,
145+
url: string,
146+
redactUrl?: (url: string) => string
147+
): Attributes {
130148
const attrs: Attributes = {
131149
[ATTR_HTTP_REQUEST_METHOD]: method,
132-
[ATTR_URL_FULL]: url,
150+
// server.* below come from the original URL; only the stored full URL is
151+
// redacted, so host/port resolution is unaffected by the redactor.
152+
[ATTR_URL_FULL]: redactUrl ? redactUrl(url) : url,
133153
};
134154
try {
135155
const parsed = new URL(url);
@@ -198,7 +218,7 @@ function buildWrappedFetch(
198218
if (staticAttributes) {
199219
span.setAttributes(staticAttributes);
200220
}
201-
span.setAttributes(fetchAttributes(name, url));
221+
span.setAttributes(fetchAttributes(name, url, options?.redactUrl));
202222
try {
203223
const headers = buildPropagatedHeaders(input, init);
204224
const response = await callOriginal(original, input, init, headers);

0 commit comments

Comments
 (0)