// ---------------------------------------------------------------------------// URL redaction at the cloud span-processor seam.//// The redaction rules themselves live in `@executor-js/sdk`// (`telemetry-url-redaction`) and are shared by every exporter path — this// module only adapts them to the OpenTelemetry SDK's span-processor// interface, which is the one chokepoint every span in a cloud isolate must// pass through on its way to the exporter: worker spans, Effect spans,// Durable Object spans, and any route added later. A per-route middleware or// a `TracerDisabledWhen` override would only cover the routes someone// remembered to wire it into.//// Four channels are scrubbed: attributes (`url.full` / `url.query` stamped// unconditionally by Effect's HttpMiddleware.tracer and HttpClient), event// attributes (`exception.message` / `exception.stacktrace` carry the raw URL// of a failed request), link attributes (a link to a peer span carries the// peer's own attribute bag), and the status message. `url.path` is// deliberately preserved — route-level visibility is what makes these traces// worth exporting at all.// --------------------------------------------------------------------------- import type { Context } from "@opentelemetry/api";import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; import { redactSpanUrlAttributes, redactStringElements, redactUrlsInText, STRIPPED_QUERY_ATTRIBUTE,} from "@executor-js/sdk"; /** The mutable surface of a span the redactor needs: the attribute bag, the * recorded events (exception events carry `exception.message` and * `exception.stacktrace`), the links (each carries its own attribute bag), * and the status (whose `message` carries the failing error's message). Both * `Span` and `ReadableSpan` expose all four as plain mutable objects. */interface RedactableSpan { readonly attributes: Record<string, unknown>; readonly events: ReadonlyArray<{ name: string; attributes?: Record<string, unknown> | undefined; }>; readonly links: ReadonlyArray<{ attributes?: Record<string, unknown> | undefined; }>; readonly status: { message?: string | undefined };} /** Wraps a span processor so every span is scrubbed of credential-bearing URL * components — in attributes, in event attributes, in link attributes, and * in the status message — before the inner processor (and therefore the * exporter) sees it. * * The attribute rewrite happens in `onEnding`, the last hook the OTel SDK * calls while the span is still mutable (`Span.end()` runs `onEnding` before * setting `_ended`, so `setAttribute` still applies); `onEnd` receives a * frozen `ReadableSpan`. `onEnding` is optional in the SpanProcessor * interface, so `onEnd` re-checks the attribute bag and mutates it directly * as a backstop for any SDK path that skips the earlier hook. Events and * status have no setter on an ended span in either hook, so they are always * scrubbed by direct mutation. */export class UrlRedactingSpanProcessor implements SpanProcessor { constructor(private readonly inner: SpanProcessor) {} forceFlush(): Promise<void> { return this.inner.forceFlush(); } onStart(span: Span, parentContext: Context): void { this.inner.onStart(span, parentContext); } onEnding(span: Span): void { this.redact(span, (key, value) => span.setAttribute(key, value)); this.inner.onEnding?.(span); } onEnd(span: ReadableSpan): void { this.redact(span, (key, value) => { span.attributes[key] = value; }); this.inner.onEnd(span); } shutdown(): Promise<void> { return this.inner.shutdown(); } private redact(span: RedactableSpan, write: (key: string, value: string) => void): void { // Work on a copy so the redaction decision is made from the current values // and applied through the caller's writer (span API vs direct mutation). const draft: Record<string, unknown> = { ...span.attributes }; const stripped = new Set(redactSpanUrlAttributes(draft)); for (const [name, value] of Object.entries(draft)) { if (typeof value === "string" && value !== span.attributes[name]) write(name, value); } // Links have no setter on an ended span, so their attribute bags are // scrubbed by direct mutation — the same full URL-aware pass the span's // own attributes get, since a link carries an arbitrary attribute bag. for (const link of span.links) { if (link.attributes === undefined) continue; for (const key of redactSpanUrlAttributes(link.attributes)) stripped.add(key); } if (stripped.size > 0) write(STRIPPED_QUERY_ATTRIBUTE, Array.from(stripped).sort().join(",")); for (const event of span.events) { const name = redactUrlsInText(event.name); if (name !== event.name) event.name = name; if (event.attributes === undefined) continue; for (const [key, value] of Object.entries(event.attributes)) { // Event attributes permit string[] exactly as span attributes do, so // array elements get the same free-text scrub, in place. if (Array.isArray(value)) { redactStringElements(value, redactUrlsInText); continue; } if (typeof value !== "string") continue; const redacted = redactUrlsInText(value); if (redacted !== value) event.attributes[key] = redacted; } } const message = span.status.message; if (typeof message === "string") { const redacted = redactUrlsInText(message); if (redacted !== message) span.status.message = redacted; } }}
// ---------------------------------------------------------------------------
// URL redaction at the cloud span-processor seam.
//
// The redaction rules themselves live in `@executor-js/sdk`
// (`telemetry-url-redaction`) and are shared by every exporter path — this
// module only adapts them to the OpenTelemetry SDK's span-processor
// interface, which is the one chokepoint every span in a cloud isolate must
// pass through on its way to the exporter: worker spans, Effect spans,
// Durable Object spans, and any route added later. A per-route middleware or
// a `TracerDisabledWhen` override would only cover the routes someone
// remembered to wire it into.
//
// Four channels are scrubbed: attributes (`url.full` / `url.query` stamped
// unconditionally by Effect's HttpMiddleware.tracer and HttpClient), event
// attributes (`exception.message` / `exception.stacktrace` carry the raw URL
// of a failed request), link attributes (a link to a peer span carries the
// peer's own attribute bag), and the status message. `url.path` is
// deliberately preserved — route-level visibility is what makes these traces
// worth exporting at all.
// ---------------------------------------------------------------------------
import type { Context } from "@opentelemetry/api";
import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
import {
redactSpanUrlAttributes,
redactStringElements,
redactUrlsInText,
STRIPPED_QUERY_ATTRIBUTE,
} from "@executor-js/sdk";
/** The mutable surface of a span the redactor needs: the attribute bag, the
* recorded events (exception events carry `exception.message` and
* `exception.stacktrace`), the links (each carries its own attribute bag),
* and the status (whose `message` carries the failing error's message). Both
* `Span` and `ReadableSpan` expose all four as plain mutable objects. */
interface RedactableSpan {
readonly attributes: Record<string, unknown>;
readonly events: ReadonlyArray<{
name: string;
attributes?: Record<string, unknown> | undefined;
}>;
readonly links: ReadonlyArray<{
attributes?: Record<string, unknown> | undefined;
}>;
readonly status: { message?: string | undefined };
}
/** Wraps a span processor so every span is scrubbed of credential-bearing URL
* components — in attributes, in event attributes, in link attributes, and
* in the status message — before the inner processor (and therefore the
* exporter) sees it.
*
* The attribute rewrite happens in `onEnding`, the last hook the OTel SDK
* calls while the span is still mutable (`Span.end()` runs `onEnding` before
* setting `_ended`, so `setAttribute` still applies); `onEnd` receives a
* frozen `ReadableSpan`. `onEnding` is optional in the SpanProcessor
* interface, so `onEnd` re-checks the attribute bag and mutates it directly
* as a backstop for any SDK path that skips the earlier hook. Events and
* status have no setter on an ended span in either hook, so they are always
* scrubbed by direct mutation. */
export class UrlRedactingSpanProcessor implements SpanProcessor {
constructor(private readonly inner: SpanProcessor) {}
forceFlush(): Promise<void> {
return this.inner.forceFlush();
}
onStart(span: Span, parentContext: Context): void {
this.inner.onStart(span, parentContext);
}
onEnding(span: Span): void {
this.redact(span, (key, value) => span.setAttribute(key, value));
this.inner.onEnding?.(span);
}
onEnd(span: ReadableSpan): void {
this.redact(span, (key, value) => {
span.attributes[key] = value;
});
this.inner.onEnd(span);
}
shutdown(): Promise<void> {
return this.inner.shutdown();
}
private redact(span: RedactableSpan, write: (key: string, value: string) => void): void {
// Work on a copy so the redaction decision is made from the current values
// and applied through the caller's writer (span API vs direct mutation).
const draft: Record<string, unknown> = { ...span.attributes };
const stripped = new Set(redactSpanUrlAttributes(draft));
for (const [name, value] of Object.entries(draft)) {
if (typeof value === "string" && value !== span.attributes[name]) write(name, value);
}
// Links have no setter on an ended span, so their attribute bags are
// scrubbed by direct mutation — the same full URL-aware pass the span's
// own attributes get, since a link carries an arbitrary attribute bag.
for (const link of span.links) {
if (link.attributes === undefined) continue;
for (const key of redactSpanUrlAttributes(link.attributes)) stripped.add(key);
}
if (stripped.size > 0) write(STRIPPED_QUERY_ATTRIBUTE, Array.from(stripped).sort().join(","));
for (const event of span.events) {
const name = redactUrlsInText(event.name);
if (name !== event.name) event.name = name;
if (event.attributes === undefined) continue;
for (const [key, value] of Object.entries(event.attributes)) {
// Event attributes permit string[] exactly as span attributes do, so
// array elements get the same free-text scrub, in place.
if (Array.isArray(value)) {
redactStringElements(value, redactUrlsInText);
continue;
}
if (typeof value !== "string") continue;
const redacted = redactUrlsInText(value);
if (redacted !== value) event.attributes[key] = redacted;
}
}
const message = span.status.message;
if (typeof message === "string") {
const redacted = redactUrlsInText(message);
if (redacted !== message) span.status.message = redacted;
}
}
}