From dc203683fc6a842a989d22e29713bf7360125a84 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:24:27 +0200 Subject: [PATCH 1/3] Add HttpClient tracer header filter agent-session-id: 133b5d2a-3d5d-4f52-8f55-f12a834f6b13 agent-tool: Codex CLI agent-tool-version: 0.145.0 agent-model: unknown agent-runtime-profile: /nix/store/ph8rlhdj25mg71v81jsfzy6dq4xpcs9m-coding-agent-runtime-profile/share/coding-agents/profile.json agent-skills-manifest: /nix/store/lsykz8x5481xrpbgk280xh3pypk1c5jy-agent-skills-corpus/share/agent-skills/manifest.json tooling-profile: dotfiles@3649b53 --- .../add-http-client-tracer-header-filter.md | 5 ++ .../effect/src/unstable/http/HttpClient.ts | 15 ++++ .../test/unstable/http/HttpClient.test.ts | 72 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 .changeset/add-http-client-tracer-header-filter.md diff --git a/.changeset/add-http-client-tracer-header-filter.md b/.changeset/add-http-client-tracer-header-filter.md new file mode 100644 index 00000000000..44d80446792 --- /dev/null +++ b/.changeset/add-http-client-tracer-header-filter.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Add a configurable filter for HTTP client request and response header span attributes. diff --git a/packages/effect/src/unstable/http/HttpClient.ts b/packages/effect/src/unstable/http/HttpClient.ts index 7e28a67ca22..0110bfaf6d2 100644 --- a/packages/effect/src/unstable/http/HttpClient.ts +++ b/packages/effect/src/unstable/http/HttpClient.ts @@ -687,8 +687,10 @@ export const make = ( span.attribute("url.query", query) } const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames) + const headerFilter = fiber.getRef(TracerHeaderFilter) const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) for (const name in redactedHeaders) { + if (!headerFilter(name)) continue span.attribute(`http.request.header.${name}`, String(redactedHeaders[name])) } request = fiber.getRef(TracerPropagationEnabled) @@ -702,6 +704,7 @@ export const make = ( span.attribute("http.response.status_code", response.status) const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames) for (const name in redactedHeaders) { + if (!headerFilter(name)) continue span.attribute(`http.response.header.${name}`, String(redactedHeaders[name])) } @@ -1508,6 +1511,18 @@ export const TracerDisabledWhen = Context.Reference< defaultValue: () => constFalse }) +/** + * Context reference for filtering request and response headers added to client spans. + * + * @category references + * @since 4.0.0 + */ +export const TracerHeaderFilter = Context.Reference< + Predicate.Predicate +>("effect/http/HttpClient/TracerHeaderFilter", { + defaultValue: () => constTrue +}) + /** * Context reference that controls whether outgoing client spans are propagated to request headers. * diff --git a/packages/effect/test/unstable/http/HttpClient.test.ts b/packages/effect/test/unstable/http/HttpClient.test.ts index 34c47c9f74a..9b85be5f6a1 100644 --- a/packages/effect/test/unstable/http/HttpClient.test.ts +++ b/packages/effect/test/unstable/http/HttpClient.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { strictEqual } from "@effect/vitest/utils" import { Clock, Duration, Effect, Fiber, Layer, Ref, Stream } from "effect" import { TestClock } from "effect/testing" +import * as Tracer from "effect/Tracer" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { RateLimiter } from "effect/unstable/persistence" @@ -37,6 +38,77 @@ const makeRedirectClient = Effect.fnUntraced(function*(status: number, location: const RateLimiterTestLayer = RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory)) describe("HttpClient", () => { + describe("tracer", () => { + it.effect("includes request and response headers by default", () => + Effect.gen(function*() { + let clientSpan: Tracer.NativeSpan | undefined + const tracer = Tracer.make({ + span(options) { + clientSpan = new Tracer.NativeSpan(options) + return clientSpan + } + }) + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + headers: { "x-response-default": "response" } + }) + ) + ) + ) + + yield* client.get("http://test/", { + headers: { "x-request-default": "request" } + }).pipe(Effect.provideService(Tracer.Tracer, tracer)) + + assert(clientSpan !== undefined) + assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-default"), "request") + assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-default"), "response") + })) + + it.effect("filters request and response header span attributes", () => + Effect.gen(function*() { + let clientSpan: Tracer.NativeSpan | undefined + const tracer = Tracer.make({ + span(options) { + clientSpan = new Tracer.NativeSpan(options) + return clientSpan + } + }) + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + headers: { + "x-response-drop": "drop", + "x-response-keep": "keep" + } + }) + ) + ) + ) + + yield* client.get("http://test/", { + headers: { + "x-request-drop": "drop", + "x-request-keep": "keep" + } + }).pipe( + Effect.provideService(HttpClient.TracerHeaderFilter, (name) => name.endsWith("-keep")), + Effect.provideService(Tracer.Tracer, tracer) + ) + + assert(clientSpan !== undefined) + assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-drop"), undefined) + assert.strictEqual(clientSpan.attributes.get("http.request.header.x-request-keep"), "keep") + assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-drop"), undefined) + assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-keep"), "keep") + })) + }) + describe("followRedirects", () => { it.effect("preserves credential headers on same-origin redirects", () => Effect.gen(function*() { From d093ce6d42bb6754cd5ad1ff78afdc46958a4dd0 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 31 Jul 2026 11:16:27 +1200 Subject: [PATCH 2/3] Apply suggestion from @tim-smart --- packages/effect/src/unstable/http/HttpClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect/src/unstable/http/HttpClient.ts b/packages/effect/src/unstable/http/HttpClient.ts index 0110bfaf6d2..a0f141dc67e 100644 --- a/packages/effect/src/unstable/http/HttpClient.ts +++ b/packages/effect/src/unstable/http/HttpClient.ts @@ -1518,7 +1518,7 @@ export const TracerDisabledWhen = Context.Reference< * @since 4.0.0 */ export const TracerHeaderFilter = Context.Reference< - Predicate.Predicate + (headerName: string, phase: "request" | "response") => boolean >("effect/http/HttpClient/TracerHeaderFilter", { defaultValue: () => constTrue }) From dd259e8666ff1fe252e504f75efd8b62acf4a73e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Fri, 31 Jul 2026 11:22:26 +1200 Subject: [PATCH 3/3] Wire HttpClient header filter phases --- .../effect/src/unstable/http/HttpClient.ts | 4 +-- .../test/unstable/http/HttpClient.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/unstable/http/HttpClient.ts b/packages/effect/src/unstable/http/HttpClient.ts index a0f141dc67e..bb4e66f0a60 100644 --- a/packages/effect/src/unstable/http/HttpClient.ts +++ b/packages/effect/src/unstable/http/HttpClient.ts @@ -690,7 +690,7 @@ export const make = ( const headerFilter = fiber.getRef(TracerHeaderFilter) const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) for (const name in redactedHeaders) { - if (!headerFilter(name)) continue + if (!headerFilter(name, "request")) continue span.attribute(`http.request.header.${name}`, String(redactedHeaders[name])) } request = fiber.getRef(TracerPropagationEnabled) @@ -704,7 +704,7 @@ export const make = ( span.attribute("http.response.status_code", response.status) const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames) for (const name in redactedHeaders) { - if (!headerFilter(name)) continue + if (!headerFilter(name, "response")) continue span.attribute(`http.response.header.${name}`, String(redactedHeaders[name])) } diff --git a/packages/effect/test/unstable/http/HttpClient.test.ts b/packages/effect/test/unstable/http/HttpClient.test.ts index 9b85be5f6a1..c5ac1000929 100644 --- a/packages/effect/test/unstable/http/HttpClient.test.ts +++ b/packages/effect/test/unstable/http/HttpClient.test.ts @@ -107,6 +107,38 @@ describe("HttpClient", () => { assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-drop"), undefined) assert.strictEqual(clientSpan.attributes.get("http.response.header.x-response-keep"), "keep") })) + + it.effect("filters the same header name independently by phase", () => + Effect.gen(function*() { + let clientSpan: Tracer.NativeSpan | undefined + const tracer = Tracer.make({ + span(options) { + clientSpan = new Tracer.NativeSpan(options) + return clientSpan + } + }) + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + headers: { "x-phase-filter": "response" } + }) + ) + ) + ) + + yield* client.get("http://test/", { + headers: { "x-phase-filter": "request" } + }).pipe( + Effect.provideService(HttpClient.TracerHeaderFilter, (_name, phase) => phase === "response"), + Effect.provideService(Tracer.Tracer, tracer) + ) + + assert(clientSpan !== undefined) + assert.strictEqual(clientSpan.attributes.get("http.request.header.x-phase-filter"), undefined) + assert.strictEqual(clientSpan.attributes.get("http.response.header.x-phase-filter"), "response") + })) }) describe("followRedirects", () => {