90 lines · 4.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Export-seam redaction for the BROWSER isolate's OTLP pipeline.//// The web client's `OtlpTracer` stamps the same credential-bearing values the// worker's tracer does — `HttpClient` writes `url.full` and `url.query` on// every `http.client` span, and a failed span carries the interpolated log// message, `effect.cause`, and `exception.*` as event text — and ships them to// `/v1/traces`, which the worker forwards to Axiom. The policy is shared with// the Workers isolates (`@executor-js/sdk/shared`); only the seam differs.//// `OtlpTracer` has no processor hook: it builds the OTLP payload itself and// hands it to `OtlpSerialization`. That handoff is the one chokepoint every// exported span crosses, so the redaction wraps the serializer rather than the// tracer — a tracer wrapper would have to re-implement `Tracer.Span`, and a// per-request `HttpClient` transform would only cover the clients someone// remembered to wire it into.// --------------------------------------------------------------------------- import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import { OtlpSerialization } from "effect/unstable/observability/OtlpSerialization";import type { TraceData } from "effect/unstable/observability/OtlpTracer"; import {  STRIPPED_QUERY_ATTRIBUTE,  redactSpanUrlAttribute,  scrubSpanText,} from "@executor-js/sdk/shared"; /** The OTLP span, taken off `TraceData` rather than redeclared — the payload *  shape is the contract this wrapper sits on. */type OtlpSpan = TraceData["resourceSpans"][number]["scopeSpans"][number]["spans"][number]; /** Rewrite one span's payload in place. The `TraceData` handed to the *  serializer is freshly built per export batch and is not shared with the *  tracer's own span objects, so mutating it affects only what is serialized. * *  An OTLP attribute value is a tagged union (`stringValue`, `intValue`, …); *  only the string arm can carry a URL or free text. */const scrubSpan = (span: OtlpSpan): void => {  const stripped = new Set<string>();  for (const attribute of span.attributes) {    const value = attribute.value.stringValue;    if (typeof value !== "string") continue;    const result = redactSpanUrlAttribute(attribute.key, value);    if (result === null) continue;    attribute.value.stringValue = result.value;    for (const key of result.stripped) stripped.add(key);  }  if (stripped.size > 0) {    span.attributes.push({      key: STRIPPED_QUERY_ATTRIBUTE,      value: { stringValue: Array.from(stripped).sort().join(",") },    });  }  span.events.forEach((event, index) => {    for (const attribute of event.attributes) {      const value = attribute.value.stringValue;      if (typeof value !== "string") continue;      attribute.value.stringValue = scrubSpanText(value);    }    // `name` is readonly on the event, so the entry is replaced rather than    // assigned through — the attribute bags above are shared with the copy.    span.events[index] = { ...event, name: scrubSpanText(event.name) };  });  if (typeof span.status.message === "string") {    span.status.message = scrubSpanText(span.status.message);  }}; /** Wrap an `OtlpSerialization` layer so every span it serializes is scrubbed *  first. Metrics and logs pass through untouched — neither carries a span's *  URL attributes, and the log exporter is not installed on this surface. */export const layerRedacted = (  inner: Layer.Layer<OtlpSerialization>,): Layer.Layer<OtlpSerialization> =>  Layer.effect(    OtlpSerialization,    Effect.map(OtlpSerialization.asEffect(), (serialization) => ({      ...serialization,      traces: (data) => {        for (const resourceSpan of data.resourceSpans) {          for (const scopeSpan of resourceSpan.scopeSpans) {            for (const span of scopeSpan.spans) scrubSpan(span);          }        }        return serialization.traces(data);      },    })),  ).pipe(Layer.provide(inner));