// ---------------------------------------------------------------------------// URL redaction for exported telemetry — the shared implementation every// exporter path consumes.//// Effect's `HttpMiddleware.tracer` stamps `url.full` and `url.query`// unconditionally on every `http.server` span, and `HttpClient` does the same// for outbound `http.client` spans (see `effect/unstable/http`). Those URLs// routinely carry credentials: `/api/oauth/callback` receives the provider's// `?code=…&state=…`, query-auth placements put API keys under arbitrary// parameter names (`?key=…`, `?owner=…` — the NAME is operator-chosen free// text), fragments carry implicit-grant tokens (`#access_token=…`), and// userinfo carries basic-auth passwords. Because parameter names are// arbitrary, no allowlist of "safe" names can exist: EVERY query parameter// value is dropped, every fragment is dropped, and userinfo is dropped. Only// scheme, host, and path survive — that is what makes a trace debuggable. The// parameter NAMES (never values) are reported so a trace still shows that a// request carried a `code`, without its value; a nameless segment (`?token` —// indistinguishable from a bare value) is reported as `*`. That diagnostic is// stamped only by the cloud span processor, which holds whole spans; the// serialization-seam consumers below remove secrets and report nothing.//// URLs also escape the attribute bag: when a request fails, Effect's error// types embed the raw URL in their `message` ("Transport: … (GET <url>)"),// which the exporters copy into exception events and the span status. So the// scrub has a free-text form too, applied to every exported string.//// This module is the single source of truth. Consumers:// - apps/cloud wraps its OTel span processors (`UrlRedactingSpanProcessor`)// around `redactSpanUrlAttributes`/`redactUrlsInText`;// - the self-host server and the browser client provide// `UrlRedactingOtlpSerializationJson` to their Effect OTLP exporters, so// the scrub runs at the serialization seam every span and log record// passes through;// - apps/cloud's browser-traces forwarder scrubs the decoded OTLP JSON// batch with `redactOtlpTraceExport` before forwarding it.// --------------------------------------------------------------------------- import { Layer } from "effect";import * as HttpBody from "effect/unstable/http/HttpBody";// The deep module: `effect/unstable/observability` re-exports OtlpSerialization// as a NAMESPACE, and passing the namespace to `Layer.succeed` would key the// context entry on `undefined`. The service class itself lives here.import { OtlpSerialization } from "effect/unstable/observability/OtlpSerialization"; /** Span attributes whose value is a whole URL. */const URL_ATTRIBUTES = ["url.full", "http.url"] as const;const QUERY_ATTRIBUTE = "url.query";/** Names of the parameters removed from this span's URL attributes, plus the * markers `userinfo` / `fragment` when those components were dropped and `*` * for a nameless query segment. Non-secret by construction — it is the key * list, never the values. Stamped only by the cloud span processor; the * serialization-seam paths remove secrets without reporting. */export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys"; const isUrlAttribute = (name: string): boolean => (URL_ATTRIBUTES as readonly string[]).includes(name); /** The parameter NAMES of a query string. A segment without `=` has no name — * it may be a bare credential (`?<token>`), so it is reported as `*` rather * than echoed. */const queryParameterNames = (query: string): readonly string[] => { const names = new Set<string>(); for (const segment of query.split("&")) { if (segment === "") continue; const separator = segment.indexOf("="); names.add(separator === -1 ? "*" : segment.slice(0, separator)); } return Array.from(names).sort();}; /** A URL reduced to its exportable parts, and what was removed. */export interface RedactedUrl { readonly url: string; readonly stripped: readonly string[];} /** The URL with userinfo, the entire query string, and the fragment removed. * Scheme, host, and path are untouched. An unparseable value is degraded * textually — truncated at its first `?` or `#`, then shorn of any * `user:password@` prefix — never passed through: if it cannot be parsed it * cannot be proven safe (over-stripping is the safe direction). */export const redactUrlForTelemetry = (value: string): RedactedUrl => { const stripped = new Set<string>(); if (URL.canParse(value)) { const url = new URL(value); let changed = false; if (url.username !== "" || url.password !== "") { url.username = ""; url.password = ""; stripped.add("userinfo"); changed = true; } if (url.search !== "") { for (const name of queryParameterNames(url.search.slice(1))) stripped.add(name); url.search = ""; changed = true; } if (url.hash !== "") { stripped.add("fragment"); url.hash = ""; changed = true; } return changed ? { url: url.toString(), stripped: Array.from(stripped).sort() } : { url: value, stripped: [] }; } // Malformed fallback. The `#` cut runs before the `@` scan so an `@` inside // a fragment is never misread as userinfo. const cut = value.search(/[?#]/); let head = cut === -1 ? value : value.slice(0, cut); if (cut !== -1) { const tail = value.slice(cut); const fragmentStart = tail.indexOf("#"); if (fragmentStart !== -1) stripped.add("fragment"); if (tail.startsWith("?")) { const query = fragmentStart === -1 ? tail.slice(1) : tail.slice(1, fragmentStart); for (const name of queryParameterNames(query)) stripped.add(name); } } const userinfoEnd = head.lastIndexOf("@"); if (userinfoEnd !== -1) { stripped.add("userinfo"); head = head.slice(userinfoEnd + 1); } return head === value ? { url: value, stripped: [] } : { url: head, stripped: Array.from(stripped).sort() };}; /** Matches URL-shaped substrings inside free text — error messages, stack * traces, status descriptions. The character class stops at whitespace and * common delimiters so `(GET http://…)` captures only the URL. */const URL_IN_TEXT = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>()[\]{}]+/g; /** Free text with every embedded URL redacted (userinfo, query values, and * fragments removed). This is how exception messages and status descriptions * are scrubbed — they carry the URL mid-sentence, not as a whole attribute * value. */export const redactUrlsInText = (text: string): string => text.replace(URL_IN_TEXT, (match) => redactUrlForTelemetry(match).url); /** Applies `redact` to each string element of `values` in place, leaving * non-string elements untouched. OTel attributes permit string[] values, so * an array element carries a URL exactly as a scalar does. In-place mutation * preserves the array's identity, which is how the change reaches a span * whose bag was shallow-copied before redaction. Exported for the cloud * span-processor's event-attribute walk, which redacts the same shapes. */export const redactStringElements = ( values: unknown[], redact: (value: string) => string,): void => { for (let index = 0; index < values.length; index += 1) { const element = values[index]; if (typeof element === "string") values[index] = redact(element); }}; /** Rewrites the URL-bearing attributes of a span attribute bag in place, * dropping userinfo, every query parameter value, and the fragment. Every * other string attribute is scrubbed as free text, so a URL embedded in an * error-message attribute cannot slip through either. Array values get the * same treatment element by element — OTel attributes permit string[]. * Returns the stripped parameter names/markers. */export const redactSpanUrlAttributes = (attributes: Record<string, unknown>): readonly string[] => { const stripped = new Set<string>(); const redactWholeUrl = (value: string): string => { const result = redactUrlForTelemetry(value); for (const key of result.stripped) stripped.add(key); return result.url; }; for (const name of URL_ATTRIBUTES) { const value = attributes[name]; if (Array.isArray(value)) { redactStringElements(value, redactWholeUrl); continue; } if (typeof value !== "string") continue; const redacted = redactWholeUrl(value); if (redacted !== value) attributes[name] = redacted; } // The raw query attribute never survives; its parameter names are already // reported via the stripped-keys list. const dropQuery = (value: string): string => { for (const key of queryParameterNames(value)) stripped.add(key); return ""; }; const query = attributes[QUERY_ATTRIBUTE]; if (Array.isArray(query)) { redactStringElements(query, dropQuery); } else if (typeof query === "string" && query !== "") { attributes[QUERY_ATTRIBUTE] = dropQuery(query); } for (const [name, value] of Object.entries(attributes)) { if (isUrlAttribute(name) || name === QUERY_ATTRIBUTE) continue; if (Array.isArray(value)) { redactStringElements(value, redactUrlsInText); continue; } if (typeof value !== "string") continue; const redacted = redactUrlsInText(value); if (redacted !== value) attributes[name] = redacted; } return Array.from(stripped).sort();}; // ---------------------------------------------------------------------------// OTLP export payload scrub.// --------------------------------------------------------------------------- /** Nesting bound for the payload walk. An OTLP batch is a few levels deep; * anything deeper is not a trace batch, and its content is dropped rather * than forwarded unexamined. */const MAX_SCRUB_DEPTH = 64; const scrubValue = (value: unknown, depth: number): unknown => { if (typeof value === "string") return redactUrlsInText(value); if (Array.isArray(value)) { return depth >= MAX_SCRUB_DEPTH ? [] : value.map((item) => scrubValue(item, depth + 1)); } if (value !== null && typeof value === "object") { if (depth >= MAX_SCRUB_DEPTH) return {}; const record = value as Record<string, unknown>; const result: Record<string, unknown> = {}; for (const [name, item] of Object.entries(record)) { result[name] = scrubValue(item, depth + 1); } // An OTLP KeyValue whose key names a URL attribute additionally gets the // URL-aware scrub on its string value (free-text scrubbing alone would // miss a malformed URL that the text regex does not match). This runs ON // TOP of the generic walk above — never instead of it — so a crafted // KeyValue cannot smuggle URL-bearing text through a sibling field. const key = record["key"]; const inner = record["value"]; if ( typeof key === "string" && (isUrlAttribute(key) || key === QUERY_ATTRIBUTE) && inner !== null && typeof inner === "object" ) { const text = (inner as Record<string, unknown>)["stringValue"]; if (typeof text === "string") { result["value"] = { ...(result["value"] as Record<string, unknown>), stringValue: key === QUERY_ATTRIBUTE ? "" : redactUrlForTelemetry(text).url, }; } } return result; } return value;}; /** A decoded OTLP trace-export payload (`{ resourceSpans: … }`) with every * string scrubbed of embedded URL credentials and every `url.full` / * `http.url` / `url.query` attribute value redacted. The walk is generic — * every string in the tree passes through the free-text scrub — so a * credential-bearing URL cannot hide in a field the OTLP schema does not * name. Removal only: this path adds no `url.query.stripped_keys` * diagnostic (that reporting exists only on the cloud span-processor path). */export const redactOtlpTraceExport = (payload: unknown): unknown => scrubValue(payload, 0); /** A decoded OTLP log-export payload (`{ resourceLogs: … }`) with every string * scrubbed of embedded URL credentials. Log records need this as much as * spans do: Effect's `OtlpLogger` exports `Cause.pretty` output (a failure's * error message embeds the raw URL of the request that failed) and every log * annotation verbatim. The walk is the same one traces get, including the * URL-aware KeyValue handling for annotation attributes. */export const redactOtlpLogExport = (payload: unknown): unknown => scrubValue(payload, 0); /** JSON OTLP serialization with the trace and log payloads scrubbed at the * serialization seam — the one chokepoint every exported span and log record * passes through in Effect's OTLP exporter, regardless of which layer created * it. Drop-in replacement for `OtlpSerialization.layerJson`. Metrics * serialize unchanged: they are aggregated numbers under fixed metric names, * not request-derived strings. This seam removes secrets only; the * stripped-parameter-names diagnostic is stamped solely by the cloud span * processor, which sees spans as attribute bags rather than serialized * batches. */export const UrlRedactingOtlpSerializationJson: Layer.Layer<OtlpSerialization> = Layer.succeed( OtlpSerialization, { traces: (spans) => HttpBody.jsonUnsafe(redactOtlpTraceExport(spans)), metrics: (metrics) => HttpBody.jsonUnsafe(metrics), logs: (logs) => HttpBody.jsonUnsafe(redactOtlpLogExport(logs)), },);
// ---------------------------------------------------------------------------
// URL redaction for exported telemetry — the shared implementation every
// exporter path consumes.
//
// Effect's `HttpMiddleware.tracer` stamps `url.full` and `url.query`
// unconditionally on every `http.server` span, and `HttpClient` does the same
// for outbound `http.client` spans (see `effect/unstable/http`). Those URLs
// routinely carry credentials: `/api/oauth/callback` receives the provider's
// `?code=…&state=…`, query-auth placements put API keys under arbitrary
// parameter names (`?key=…`, `?owner=…` — the NAME is operator-chosen free
// text), fragments carry implicit-grant tokens (`#access_token=…`), and
// userinfo carries basic-auth passwords. Because parameter names are
// arbitrary, no allowlist of "safe" names can exist: EVERY query parameter
// value is dropped, every fragment is dropped, and userinfo is dropped. Only
// scheme, host, and path survive — that is what makes a trace debuggable. The
// parameter NAMES (never values) are reported so a trace still shows that a
// request carried a `code`, without its value; a nameless segment (`?token` —
// indistinguishable from a bare value) is reported as `*`. That diagnostic is
// stamped only by the cloud span processor, which holds whole spans; the
// serialization-seam consumers below remove secrets and report nothing.
//
// URLs also escape the attribute bag: when a request fails, Effect's error
// types embed the raw URL in their `message` ("Transport: … (GET <url>)"),
// which the exporters copy into exception events and the span status. So the
// scrub has a free-text form too, applied to every exported string.
//
// This module is the single source of truth. Consumers:
// - apps/cloud wraps its OTel span processors (`UrlRedactingSpanProcessor`)
// around `redactSpanUrlAttributes`/`redactUrlsInText`;
// - the self-host server and the browser client provide
// `UrlRedactingOtlpSerializationJson` to their Effect OTLP exporters, so
// the scrub runs at the serialization seam every span and log record
// passes through;
// - apps/cloud's browser-traces forwarder scrubs the decoded OTLP JSON
// batch with `redactOtlpTraceExport` before forwarding it.
// ---------------------------------------------------------------------------
import { Layer } from "effect";
import * as HttpBody from "effect/unstable/http/HttpBody";
// The deep module: `effect/unstable/observability` re-exports OtlpSerialization
// as a NAMESPACE, and passing the namespace to `Layer.succeed` would key the
// context entry on `undefined`. The service class itself lives here.
import { OtlpSerialization } from "effect/unstable/observability/OtlpSerialization";
/** Span attributes whose value is a whole URL. */
const URL_ATTRIBUTES = ["url.full", "http.url"] as const;
const QUERY_ATTRIBUTE = "url.query";
/** Names of the parameters removed from this span's URL attributes, plus the
* markers `userinfo` / `fragment` when those components were dropped and `*`
* for a nameless query segment. Non-secret by construction — it is the key
* list, never the values. Stamped only by the cloud span processor; the
* serialization-seam paths remove secrets without reporting. */
export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys";
const isUrlAttribute = (name: string): boolean =>
(URL_ATTRIBUTES as readonly string[]).includes(name);
/** The parameter NAMES of a query string. A segment without `=` has no name —
* it may be a bare credential (`?<token>`), so it is reported as `*` rather
* than echoed. */
const queryParameterNames = (query: string): readonly string[] => {
const names = new Set<string>();
for (const segment of query.split("&")) {
if (segment === "") continue;
const separator = segment.indexOf("=");
names.add(separator === -1 ? "*" : segment.slice(0, separator));
}
return Array.from(names).sort();
};
/** A URL reduced to its exportable parts, and what was removed. */
export interface RedactedUrl {
readonly url: string;
readonly stripped: readonly string[];
}
/** The URL with userinfo, the entire query string, and the fragment removed.
* Scheme, host, and path are untouched. An unparseable value is degraded
* textually — truncated at its first `?` or `#`, then shorn of any
* `user:password@` prefix — never passed through: if it cannot be parsed it
* cannot be proven safe (over-stripping is the safe direction). */
export const redactUrlForTelemetry = (value: string): RedactedUrl => {
const stripped = new Set<string>();
if (URL.canParse(value)) {
const url = new URL(value);
let changed = false;
if (url.username !== "" || url.password !== "") {
url.username = "";
url.password = "";
stripped.add("userinfo");
changed = true;
}
if (url.search !== "") {
for (const name of queryParameterNames(url.search.slice(1))) stripped.add(name);
url.search = "";
changed = true;
}
if (url.hash !== "") {
stripped.add("fragment");
url.hash = "";
changed = true;
}
return changed
? { url: url.toString(), stripped: Array.from(stripped).sort() }
: { url: value, stripped: [] };
}
// Malformed fallback. The `#` cut runs before the `@` scan so an `@` inside
// a fragment is never misread as userinfo.
const cut = value.search(/[?#]/);
let head = cut === -1 ? value : value.slice(0, cut);
if (cut !== -1) {
const tail = value.slice(cut);
const fragmentStart = tail.indexOf("#");
if (fragmentStart !== -1) stripped.add("fragment");
if (tail.startsWith("?")) {
const query = fragmentStart === -1 ? tail.slice(1) : tail.slice(1, fragmentStart);
for (const name of queryParameterNames(query)) stripped.add(name);
}
}
const userinfoEnd = head.lastIndexOf("@");
if (userinfoEnd !== -1) {
stripped.add("userinfo");
head = head.slice(userinfoEnd + 1);
}
return head === value
? { url: value, stripped: [] }
: { url: head, stripped: Array.from(stripped).sort() };
};
/** Matches URL-shaped substrings inside free text — error messages, stack
* traces, status descriptions. The character class stops at whitespace and
* common delimiters so `(GET http://…)` captures only the URL. */
const URL_IN_TEXT = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>()[\]{}]+/g;
/** Free text with every embedded URL redacted (userinfo, query values, and
* fragments removed). This is how exception messages and status descriptions
* are scrubbed — they carry the URL mid-sentence, not as a whole attribute
* value. */
export const redactUrlsInText = (text: string): string =>
text.replace(URL_IN_TEXT, (match) => redactUrlForTelemetry(match).url);
/** Applies `redact` to each string element of `values` in place, leaving
* non-string elements untouched. OTel attributes permit string[] values, so
* an array element carries a URL exactly as a scalar does. In-place mutation
* preserves the array's identity, which is how the change reaches a span
* whose bag was shallow-copied before redaction. Exported for the cloud
* span-processor's event-attribute walk, which redacts the same shapes. */
export const redactStringElements = (
values: unknown[],
redact: (value: string) => string,
): void => {
for (let index = 0; index < values.length; index += 1) {
const element = values[index];
if (typeof element === "string") values[index] = redact(element);
}
};
/** Rewrites the URL-bearing attributes of a span attribute bag in place,
* dropping userinfo, every query parameter value, and the fragment. Every
* other string attribute is scrubbed as free text, so a URL embedded in an
* error-message attribute cannot slip through either. Array values get the
* same treatment element by element — OTel attributes permit string[].
* Returns the stripped parameter names/markers. */
export const redactSpanUrlAttributes = (attributes: Record<string, unknown>): readonly string[] => {
const stripped = new Set<string>();
const redactWholeUrl = (value: string): string => {
const result = redactUrlForTelemetry(value);
for (const key of result.stripped) stripped.add(key);
return result.url;
};
for (const name of URL_ATTRIBUTES) {
const value = attributes[name];
if (Array.isArray(value)) {
redactStringElements(value, redactWholeUrl);
continue;
}
if (typeof value !== "string") continue;
const redacted = redactWholeUrl(value);
if (redacted !== value) attributes[name] = redacted;
}
// The raw query attribute never survives; its parameter names are already
// reported via the stripped-keys list.
const dropQuery = (value: string): string => {
for (const key of queryParameterNames(value)) stripped.add(key);
return "";
};
const query = attributes[QUERY_ATTRIBUTE];
if (Array.isArray(query)) {
redactStringElements(query, dropQuery);
} else if (typeof query === "string" && query !== "") {
attributes[QUERY_ATTRIBUTE] = dropQuery(query);
}
for (const [name, value] of Object.entries(attributes)) {
if (isUrlAttribute(name) || name === QUERY_ATTRIBUTE) continue;
if (Array.isArray(value)) {
redactStringElements(value, redactUrlsInText);
continue;
}
if (typeof value !== "string") continue;
const redacted = redactUrlsInText(value);
if (redacted !== value) attributes[name] = redacted;
}
return Array.from(stripped).sort();
};
// ---------------------------------------------------------------------------
// OTLP export payload scrub.
// ---------------------------------------------------------------------------
/** Nesting bound for the payload walk. An OTLP batch is a few levels deep;
* anything deeper is not a trace batch, and its content is dropped rather
* than forwarded unexamined. */
const MAX_SCRUB_DEPTH = 64;
const scrubValue = (value: unknown, depth: number): unknown => {
if (typeof value === "string") return redactUrlsInText(value);
if (Array.isArray(value)) {
return depth >= MAX_SCRUB_DEPTH ? [] : value.map((item) => scrubValue(item, depth + 1));
}
if (value !== null && typeof value === "object") {
if (depth >= MAX_SCRUB_DEPTH) return {};
const record = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [name, item] of Object.entries(record)) {
result[name] = scrubValue(item, depth + 1);
}
// An OTLP KeyValue whose key names a URL attribute additionally gets the
// URL-aware scrub on its string value (free-text scrubbing alone would
// miss a malformed URL that the text regex does not match). This runs ON
// TOP of the generic walk above — never instead of it — so a crafted
// KeyValue cannot smuggle URL-bearing text through a sibling field.
const key = record["key"];
const inner = record["value"];
if (
typeof key === "string" &&
(isUrlAttribute(key) || key === QUERY_ATTRIBUTE) &&
inner !== null &&
typeof inner === "object"
) {
const text = (inner as Record<string, unknown>)["stringValue"];
if (typeof text === "string") {
result["value"] = {
...(result["value"] as Record<string, unknown>),
stringValue: key === QUERY_ATTRIBUTE ? "" : redactUrlForTelemetry(text).url,
};
}
}
return result;
}
return value;
};
/** A decoded OTLP trace-export payload (`{ resourceSpans: … }`) with every
* string scrubbed of embedded URL credentials and every `url.full` /
* `http.url` / `url.query` attribute value redacted. The walk is generic —
* every string in the tree passes through the free-text scrub — so a
* credential-bearing URL cannot hide in a field the OTLP schema does not
* name. Removal only: this path adds no `url.query.stripped_keys`
* diagnostic (that reporting exists only on the cloud span-processor path). */
export const redactOtlpTraceExport = (payload: unknown): unknown => scrubValue(payload, 0);
/** A decoded OTLP log-export payload (`{ resourceLogs: … }`) with every string
* scrubbed of embedded URL credentials. Log records need this as much as
* spans do: Effect's `OtlpLogger` exports `Cause.pretty` output (a failure's
* error message embeds the raw URL of the request that failed) and every log
* annotation verbatim. The walk is the same one traces get, including the
* URL-aware KeyValue handling for annotation attributes. */
export const redactOtlpLogExport = (payload: unknown): unknown => scrubValue(payload, 0);
/** JSON OTLP serialization with the trace and log payloads scrubbed at the
* serialization seam — the one chokepoint every exported span and log record
* passes through in Effect's OTLP exporter, regardless of which layer created
* it. Drop-in replacement for `OtlpSerialization.layerJson`. Metrics
* serialize unchanged: they are aggregated numbers under fixed metric names,
* not request-derived strings. This seam removes secrets only; the
* stripped-parameter-names diagnostic is stamped solely by the cloud span
* processor, which sees spans as attribute bags rather than serialized
* batches. */
export const UrlRedactingOtlpSerializationJson: Layer.Layer<OtlpSerialization> = Layer.succeed(
OtlpSerialization,
{
traces: (spans) => HttpBody.jsonUnsafe(redactOtlpTraceExport(spans)),
metrics: (metrics) => HttpBody.jsonUnsafe(metrics),
logs: (logs) => HttpBody.jsonUnsafe(redactOtlpLogExport(logs)),
},
);