Skip to content
Closed
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/platform": patch
---

feat(@effect/platform): add `withTracerRequestHeadersFilter`, `withTracerResponseHeadersFilter`, and `withTracerHeadersFilter` to HttpClient
105 changes: 105 additions & 0 deletions packages/platform/src/HttpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,111 @@ export const withSpanNameGenerator: {
<E, R>(self: HttpClient.With<E, R>, f: (request: ClientRequest.HttpClientRequest) => string): HttpClient.With<E, R>
} = 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<Predicate.Predicate<string>> =
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<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>
} = 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<Predicate.Predicate<string>> =
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<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>
} = 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<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>
} = internal.withTracerHeadersFilter

/**
* Ties the lifetime of the `HttpClientRequest` to a `Scope`.
*
Expand Down
64 changes: 61 additions & 3 deletions packages/platform/src/internal/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -98,6 +98,58 @@ export const withSpanNameGenerator = dual<
) => Client.HttpClient.With<E, R>
>(2, (self, f) => transformResponse(self, Effect.provideService(SpanNameGenerator, f)))

/** @internal */
export const currentTracerRequestHeadersFilter = globalValue(
Symbol.for("@effect/platform/HttpClient/tracerRequestHeadersFilter"),
() => FiberRef.unsafeMake<Predicate.Predicate<string>>(constTrue)
)

/** @internal */
export const withTracerRequestHeadersFilter = dual<
(
predicate: Predicate.Predicate<string>
) => <E, R>(self: Client.HttpClient.With<E, R>) => Client.HttpClient.With<E, R>,
<E, R>(
self: Client.HttpClient.With<E, R>,
predicate: Predicate.Predicate<string>
) => Client.HttpClient.With<E, R>
>(2, (self, predicate) => transformResponse(self, Effect.locally(currentTracerRequestHeadersFilter, predicate)))

/** @internal */
export const currentTracerResponseHeadersFilter = globalValue(
Symbol.for("@effect/platform/HttpClient/tracerResponseHeadersFilter"),
() => FiberRef.unsafeMake<Predicate.Predicate<string>>(constTrue)
)

/** @internal */
export const withTracerResponseHeadersFilter = dual<
(
predicate: Predicate.Predicate<string>
) => <E, R>(self: Client.HttpClient.With<E, R>) => Client.HttpClient.With<E, R>,
<E, R>(
self: Client.HttpClient.With<E, R>,
predicate: Predicate.Predicate<string>
) => Client.HttpClient.With<E, R>
>(2, (self, predicate) => transformResponse(self, Effect.locally(currentTracerResponseHeadersFilter, predicate)))

/** @internal */
export const withTracerHeadersFilter = dual<
(
predicate: Predicate.Predicate<string>
) => <E, R>(self: Client.HttpClient.With<E, R>) => Client.HttpClient.With<E, R>,
<E, R>(
self: Client.HttpClient.With<E, R>,
predicate: Predicate.Predicate<string>
) => Client.HttpClient.With<E, R>
>(2, (self, predicate) =>
transformResponse(
self,
flow(
Effect.locally(currentTracerRequestHeadersFilter, predicate),
Effect.locally(currentTracerResponseHeadersFilter, predicate)
)
))

const ClientProto = {
[TypeId]: TypeId,
pipe() {
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
111 changes: 110 additions & 1 deletion packages/platform/test/HttpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.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 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.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 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.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 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
Expand Down
Loading