diff --git a/.changeset/httpclient-tracer-header-filter.md b/.changeset/httpclient-tracer-header-filter.md new file mode 100644 index 00000000000..70629aeeb8a --- /dev/null +++ b/.changeset/httpclient-tracer-header-filter.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": patch +--- + +feat(@effect/platform): add `withTracerRequestHeadersFilter`, `withTracerResponseHeadersFilter`, and `withTracerHeadersFilter` to HttpClient diff --git a/packages/platform/src/HttpClient.ts b/packages/platform/src/HttpClient.ts index 628e9e52296..fe0eaa89967 100644 --- a/packages/platform/src/HttpClient.ts +++ b/packages/platform/src/HttpClient.ts @@ -723,6 +723,111 @@ export const withSpanNameGenerator: { (self: HttpClient.With, f: (request: ClientRequest.HttpClientRequest) => string): HttpClient.With } = internal.withSpanNameGenerator +/** + * A `FiberRef` controlling which request headers are captured as OTEL span + * attributes. The predicate receives each header name (lower-cased) and should + * return `true` to include it. Defaults to `constTrue` (capture all). + * + * @since 1.0.0 + * @category Tracing + */ +export const currentTracerRequestHeadersFilter: FiberRef.FiberRef> = + internal.currentTracerRequestHeadersFilter + +/** + * Restricts which request headers are recorded as OTEL span attributes. + * + * ```ts + * import { FetchHttpClient, HttpClient } from "@effect/platform" + * import { NodeRuntime } from "@effect/platform-node" + * import { Effect } from "effect" + * + * const allowedRequestHeaders = new Set(["content-type", "x-request-id"]) + * + * Effect.gen(function* () { + * const client = (yield* HttpClient.HttpClient).pipe( + * HttpClient.withTracerRequestHeadersFilter((name) => allowedRequestHeaders.has(name)) + * ) + * + * yield* client.get("https://api.example.com/data") + * }).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain) + * ``` + * + * @since 1.0.0 + * @category Tracing + */ +export const withTracerRequestHeadersFilter: { + (predicate: Predicate.Predicate): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, predicate: Predicate.Predicate): HttpClient.With +} = internal.withTracerRequestHeadersFilter + +/** + * A `FiberRef` controlling which response headers are captured as OTEL span + * attributes. The predicate receives each header name (lower-cased) and should + * return `true` to include it. Defaults to `constTrue` (capture all). + * + * @since 1.0.0 + * @category Tracing + */ +export const currentTracerResponseHeadersFilter: FiberRef.FiberRef> = + internal.currentTracerResponseHeadersFilter + +/** + * Restricts which response headers are recorded as OTEL span attributes. + * + * ```ts + * import { FetchHttpClient, HttpClient } from "@effect/platform" + * import { NodeRuntime } from "@effect/platform-node" + * import { Effect } from "effect" + * + * const allowedResponseHeaders = new Set(["content-type", "x-request-id", "x-ratelimit-remaining"]) + * + * Effect.gen(function* () { + * const client = (yield* HttpClient.HttpClient).pipe( + * HttpClient.withTracerResponseHeadersFilter((name) => allowedResponseHeaders.has(name)) + * ) + * + * yield* client.get("https://api.example.com/data") + * }).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain) + * ``` + * + * @since 1.0.0 + * @category Tracing + */ +export const withTracerResponseHeadersFilter: { + (predicate: Predicate.Predicate): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, predicate: Predicate.Predicate): HttpClient.With +} = internal.withTracerResponseHeadersFilter + +/** + * Restricts which headers are recorded as OTEL span attributes for both + * requests and responses. Equivalent to calling `withTracerRequestHeadersFilter` + * and `withTracerResponseHeadersFilter` with the same predicate. + * + * ```ts + * import { FetchHttpClient, HttpClient } from "@effect/platform" + * import { NodeRuntime } from "@effect/platform-node" + * import { Effect } from "effect" + * + * const allowedHeaders = new Set(["content-type", "x-request-id"]) + * + * Effect.gen(function* () { + * const client = (yield* HttpClient.HttpClient).pipe( + * HttpClient.withTracerHeadersFilter((name) => allowedHeaders.has(name)) + * ) + * + * yield* client.get("https://api.example.com/data") + * }).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain) + * ``` + * + * @since 1.0.0 + * @category Tracing + */ +export const withTracerHeadersFilter: { + (predicate: Predicate.Predicate): (self: HttpClient.With) => HttpClient.With + (self: HttpClient.With, predicate: Predicate.Predicate): HttpClient.With +} = internal.withTracerHeadersFilter + /** * Ties the lifetime of the `HttpClientRequest` to a `Scope`. * diff --git a/packages/platform/src/internal/httpClient.ts b/packages/platform/src/internal/httpClient.ts index 503016040f5..ff6cb3bd5ea 100644 --- a/packages/platform/src/internal/httpClient.ts +++ b/packages/platform/src/internal/httpClient.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context" import * as Effect from "effect/Effect" import type * as Fiber from "effect/Fiber" import * as FiberRef from "effect/FiberRef" -import { constFalse, dual, flow, identity } from "effect/Function" +import { constFalse, constTrue, dual, flow, identity } from "effect/Function" import { globalValue } from "effect/GlobalValue" import * as Inspectable from "effect/Inspectable" import * as Layer from "effect/Layer" @@ -98,6 +98,58 @@ export const withSpanNameGenerator = dual< ) => Client.HttpClient.With >(2, (self, f) => transformResponse(self, Effect.provideService(SpanNameGenerator, f))) +/** @internal */ +export const currentTracerRequestHeadersFilter = globalValue( + Symbol.for("@effect/platform/HttpClient/tracerRequestHeadersFilter"), + () => FiberRef.unsafeMake>(constTrue) +) + +/** @internal */ +export const withTracerRequestHeadersFilter = dual< + ( + predicate: Predicate.Predicate + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, + ( + self: Client.HttpClient.With, + predicate: Predicate.Predicate + ) => Client.HttpClient.With +>(2, (self, predicate) => transformResponse(self, Effect.locally(currentTracerRequestHeadersFilter, predicate))) + +/** @internal */ +export const currentTracerResponseHeadersFilter = globalValue( + Symbol.for("@effect/platform/HttpClient/tracerResponseHeadersFilter"), + () => FiberRef.unsafeMake>(constTrue) +) + +/** @internal */ +export const withTracerResponseHeadersFilter = dual< + ( + predicate: Predicate.Predicate + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, + ( + self: Client.HttpClient.With, + predicate: Predicate.Predicate + ) => Client.HttpClient.With +>(2, (self, predicate) => transformResponse(self, Effect.locally(currentTracerResponseHeadersFilter, predicate))) + +/** @internal */ +export const withTracerHeadersFilter = dual< + ( + predicate: Predicate.Predicate + ) => (self: Client.HttpClient.With) => Client.HttpClient.With, + ( + self: Client.HttpClient.With, + predicate: Predicate.Predicate + ) => Client.HttpClient.With +>(2, (self, predicate) => + transformResponse( + self, + flow( + Effect.locally(currentTracerRequestHeadersFilter, predicate), + Effect.locally(currentTracerResponseHeadersFilter, predicate) + ) + )) + const ClientProto = { [TypeId]: TypeId, pipe() { @@ -250,8 +302,11 @@ export const make = ( } const redactedHeaderNames = fiber.getFiberRef(Headers.currentRedactedNames) const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames) + const requestHeaderFilter = fiber.getFiberRef(currentTracerRequestHeadersFilter) for (const name in redactedHeaders) { - span.attribute(ATTR_HTTP_REQUEST_HEADER(name), String(redactedHeaders[name])) + if (requestHeaderFilter(name)) { + span.attribute(ATTR_HTTP_REQUEST_HEADER(name), String(redactedHeaders[name])) + } } request = fiber.getFiberRef(currentTracerPropagation) ? internalRequest.setHeaders(request, TraceContext.toHeaders(span)) @@ -263,8 +318,11 @@ export const make = ( onSuccess: (response) => { span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status) const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames) + const responseHeaderFilter = fiber.getFiberRef(currentTracerResponseHeadersFilter) for (const name in redactedHeaders) { - span.attribute(ATTR_HTTP_RESPONSE_HEADER(name), String(redactedHeaders[name])) + if (responseHeaderFilter(name)) { + span.attribute(ATTR_HTTP_RESPONSE_HEADER(name), String(redactedHeaders[name])) + } } if (scopedController) return Effect.succeed(response) responseRegistry.register(response, controller) diff --git a/packages/platform/test/HttpClient.test.ts b/packages/platform/test/HttpClient.test.ts index ca1c6067343..978969c9a3c 100644 --- a/packages/platform/test/HttpClient.test.ts +++ b/packages/platform/test/HttpClient.test.ts @@ -7,7 +7,7 @@ import { HttpClientResponse, UrlParams } from "@effect/platform" -import { describe, it } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" import { assertInclude, deepStrictEqual, strictEqual } from "@effect/vitest/utils" import { Context, @@ -18,12 +18,14 @@ import { Inspectable, Layer, Logger, + Option, pipe, Ref, Schema, Stream, Struct } from "effect" +import type { Tracer } from "effect" const Todo = Schema.Struct({ userId: Schema.Number, @@ -293,6 +295,113 @@ describe("HttpClient", () => { }) }) + it.effect("withTracerRequestHeadersFilter only captures matching request headers as span attributes", () => + Effect.gen(function*() { + const spanRef = yield* Ref.make>(Option.none()) + + const client = HttpClient.make((request) => + Effect.gen(function*() { + const span = yield* Effect.orDie(Effect.currentSpan) + yield* Ref.set(spanRef, Option.some(span)) + return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })) + }) + ).pipe( + HttpClient.withTracerRequestHeadersFilter((name) => name === "x-request-id") + ) + + yield* client.execute( + HttpClientRequest.get("http://test/").pipe( + HttpClientRequest.setHeaders({ + "x-request-id": "abc", + "content-type": "application/json", + "accept": "application/json" + }) + ) + ).pipe(Effect.ignore) + + const spanOption = yield* Ref.get(spanRef) + assert(spanOption._tag === "Some", "expected span to be captured") + const span = spanOption.value + deepStrictEqual(span.attributes.get("http.request.header.x-request-id"), "abc") + strictEqual(span.attributes.get("http.request.header.content-type"), undefined) + strictEqual(span.attributes.get("http.request.header.accept"), undefined) + })) + + it.effect("withTracerResponseHeadersFilter only captures matching response headers as span attributes", () => + Effect.gen(function*() { + const spanRef = yield* Ref.make>(Option.none()) + + const client = HttpClient.make((request) => + Effect.gen(function*() { + const span = yield* Effect.orDie(Effect.currentSpan) + yield* Ref.set(spanRef, Option.some(span)) + return HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 200, + headers: { + "content-type": "application/json", + "x-request-id": "resp-123", + "cf-ray": "abc123" + } + }) + ) + }) + ).pipe( + HttpClient.withTracerResponseHeadersFilter((name) => name === "x-request-id") + ) + + yield* client.get("http://test/").pipe(Effect.ignore) + + const spanOption = yield* Ref.get(spanRef) + assert(spanOption._tag === "Some", "expected span to be captured") + const span = spanOption.value + deepStrictEqual(span.attributes.get("http.response.header.x-request-id"), "resp-123") + strictEqual(span.attributes.get("http.response.header.content-type"), undefined) + strictEqual(span.attributes.get("http.response.header.cf-ray"), undefined) + })) + + it.effect("withTracerHeadersFilter applies the same predicate to both request and response headers", () => + Effect.gen(function*() { + const spanRef = yield* Ref.make>(Option.none()) + + const client = HttpClient.make((request) => + Effect.gen(function*() { + const span = yield* Effect.orDie(Effect.currentSpan) + yield* Ref.set(spanRef, Option.some(span)) + return HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 200, + headers: { + "x-request-id": "resp-abc", + "cf-ray": "ignored" + } + }) + ) + }) + ).pipe( + HttpClient.withTracerHeadersFilter((name) => name === "x-request-id") + ) + + yield* client.execute( + HttpClientRequest.get("http://test/").pipe( + HttpClientRequest.setHeaders({ + "x-request-id": "req-abc", + "content-type": "application/json" + }) + ) + ).pipe(Effect.ignore) + + const spanOption = yield* Ref.get(spanRef) + assert(spanOption._tag === "Some", "expected span to be captured") + const span = spanOption.value + deepStrictEqual(span.attributes.get("http.request.header.x-request-id"), "req-abc") + strictEqual(span.attributes.get("http.request.header.content-type"), undefined) + deepStrictEqual(span.attributes.get("http.response.header.x-request-id"), "resp-abc") + strictEqual(span.attributes.get("http.response.header.cf-ray"), undefined) + })) + it.effect("followRedirects", () => Effect.gen(function*() { const defaultClient = yield* HttpClient.HttpClient