Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/httpclient-tracer-header-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Add `withTracerRequestHeadersFilter`, `withTracerResponseHeadersFilter`, and `withTracerHeadersFilter` combinators to `HttpClient` for controlling which headers are captured as OTEL span attributes.
89 changes: 87 additions & 2 deletions packages/effect/src/unstable/http/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,8 +604,11 @@ export const make = (
}
const redactedHeaderNames = fiber.getRef(Headers.CurrentRedactedNames)
const redactedHeaders = Headers.redact(request.headers, redactedHeaderNames)
const requestHeaderFilter = fiber.getRef(TracerRequestHeadersFilter)
for (const name in redactedHeaders) {
span.attribute(`http.request.header.${name}`, String(redactedHeaders[name]))
if (requestHeaderFilter(name)) {
span.attribute(`http.request.header.${name}`, String(redactedHeaders[name]))
}
}
request = fiber.getRef(TracerPropagationEnabled)
? HttpClientRequest.setHeaders(request, TraceContext.toHeaders(span))
Expand All @@ -617,8 +620,11 @@ export const make = (
onSuccess: (response) => {
span.attribute("http.response.status_code", response.status)
const redactedHeaders = Headers.redact(response.headers, redactedHeaderNames)
const responseHeaderFilter = fiber.getRef(TracerResponseHeadersFilter)
for (const name in redactedHeaders) {
span.attribute(`http.response.header.${name}`, String(redactedHeaders[name]))
if (responseHeaderFilter(name)) {
span.attribute(`http.response.header.${name}`, String(redactedHeaders[name]))
}
}

if (scopedController) return Effect.succeed(response)
Expand Down Expand Up @@ -1348,6 +1354,85 @@ export const SpanNameGenerator = Context.Reference<
defaultValue: () => (request) => `http.client ${request.method}`
})

/**
* A `Context.Reference` 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 4.0.0
* @category References
*/
export const TracerRequestHeadersFilter = Context.Reference<Predicate.Predicate<string>>(
"effect/http/HttpClient/TracerRequestHeadersFilter",
{ defaultValue: () => constTrue }
)

/**
* A `Context.Reference` 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 4.0.0
* @category References
*/
export const TracerResponseHeadersFilter = Context.Reference<Predicate.Predicate<string>>(
"effect/http/HttpClient/TracerResponseHeadersFilter",
{ defaultValue: () => constTrue }
)

/**
* Restricts which request headers are recorded as OTEL span attributes.
*
* @since 4.0.0
* @category Tracing
*/
export const withTracerRequestHeadersFilter: {
(predicate: Predicate.Predicate<string>): <E, R>(self: HttpClient.With<E, R>) => HttpClient.With<E, R>
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R>
} = dual(
2,
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R> =>
transformResponse(self, Effect.provideService(TracerRequestHeadersFilter, predicate)) as HttpClient.With<E, R>
)

/**
* Restricts which response headers are recorded as OTEL span attributes.
*
* @since 4.0.0
* @category Tracing
*/
export const withTracerResponseHeadersFilter: {
(predicate: Predicate.Predicate<string>): <E, R>(self: HttpClient.With<E, R>) => HttpClient.With<E, R>
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R>
} = dual(
2,
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R> =>
transformResponse(self, Effect.provideService(TracerResponseHeadersFilter, predicate)) as HttpClient.With<E, R>
)

/**
* Restricts which headers are recorded as OTEL span attributes for both
* requests and responses. Equivalent to calling `withTracerRequestHeadersFilter`
* and `withTracerResponseHeadersFilter` with the same predicate.
*
* @since 4.0.0
* @category Tracing
*/
export const withTracerHeadersFilter: {
(predicate: Predicate.Predicate<string>): <E, R>(self: HttpClient.With<E, R>) => HttpClient.With<E, R>
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R>
} = dual(
2,
<E, R>(self: HttpClient.With<E, R>, predicate: Predicate.Predicate<string>): HttpClient.With<E, R> =>
transformResponse(
self,
flow(
Effect.provideService(TracerRequestHeadersFilter, predicate),
Effect.provideService(TracerResponseHeadersFilter, predicate)
)
) as HttpClient.With<E, R>
)

/**
* @since 4.0.0
*/
Expand Down
104 changes: 103 additions & 1 deletion packages/effect/test/HttpClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, it } from "@effect/vitest"
import { Context, Effect, Layer, Schema, Stream, Struct } from "effect"
import { deepStrictEqual, strictEqual } from "@effect/vitest/utils"
import { Context, Effect, Layer, Option, Ref, Schema, Stream, Struct, Tracer } from "effect"
import { TestClock } from "effect/testing"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"

Expand Down Expand Up @@ -122,6 +123,107 @@ const JsonPlaceholderLive = Layer.effect(JsonPlaceholder)(makeJsonPlaceholder)
})
})

it.effect("withTracerRequestHeadersFilter only captures matching request headers as span attributes", () =>
Effect.gen(function*() {
const spanRef = yield* Ref.make<Option.Option<Tracer.Span>>(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 span = Option.getOrThrow(yield* Ref.get(spanRef))
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.Option<Tracer.Span>>(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 span = Option.getOrThrow(yield* Ref.get(spanRef))
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.Option<Tracer.Span>>(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 span = Option.getOrThrow(yield* Ref.get(spanRef))
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)
}))

const flakyTest = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.timeoutOrElse({
Expand Down