feat(llm): propagate trace context to HTTP requests

This commit is contained in:
starptech 2026-07-08 23:51:07 +02:00
parent 18da3deee7
commit aacec8ea06
3 changed files with 41 additions and 31 deletions

View file

@ -131,31 +131,32 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
frames: (prepared, request, runtime) =>
LLMHttpTelemetry.stream(
prepared.request,
Stream.unwrap(
runtime.http.execute(prepared.request).pipe(
Effect.map((response) =>
Stream.unwrap(
Effect.gen(function* () {
const received = yield* LLMHttpTelemetry.ResponseReceived
if (received) yield* received(response.status)
const firstChunk = yield* LLMHttpTelemetry.ResponseChunkReceived
return prepared.framing.frame(
response.stream.pipe(
Stream.tap(() => firstChunk ?? Effect.void),
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
(providerRequest) =>
Stream.unwrap(
runtime.http.execute(providerRequest).pipe(
Effect.map((response) =>
Stream.unwrap(
Effect.gen(function* () {
const received = yield* LLMHttpTelemetry.ResponseReceived
if (received) yield* received(response.status)
const firstChunk = yield* LLMHttpTelemetry.ResponseChunkReceived
return prepared.framing.frame(
response.stream.pipe(
Stream.tap(() => firstChunk ?? Effect.void),
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
)
}),
)
}),
),
),
),
),
),
),
})

View file

@ -2,7 +2,7 @@ export * as LLMHttpTelemetry from "./http"
import { Cause, Clock, Context, Effect, Exit, Option, References, Stream } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpClient, HttpClientRequest, HttpTraceContext } from "effect/unstable/http"
import {
ATTR_ERROR_TYPE,
ATTR_HTTP_REQUEST_METHOD,
@ -40,11 +40,14 @@ const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
() => Effect.void,
)
export const stream = <A, R>(request: HttpClientRequest.HttpClientRequest, source: Stream.Stream<A, LLMError, R>) =>
export const stream = <A, R>(
request: HttpClientRequest.HttpClientRequest,
source: (request: HttpClientRequest.HttpClientRequest) => Stream.Stream<A, LLMError, R>,
) =>
Stream.unwrap(
Effect.gen(function* () {
const parent = yield* CurrentModelSpan
if (!parent) return source
if (!parent) return source(request)
const url = URL.canParse(request.url) ? new URL(request.url) : undefined
const port = url?.port
? Number(url.port)
@ -71,12 +74,16 @@ export const stream = <A, R>(request: HttpClientRequest.HttpClientRequest, sourc
})
const state: State = { responseReceived: false }
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
return observeStream(span, state, source)
return observeStream(
span,
state,
source(HttpClientRequest.setHeaders(request, HttpTraceContext.toHeaders(span))),
)
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.succeed(source),
() => Effect.succeed(source(request)),
),
),
)

View file

@ -184,7 +184,8 @@ describe("GenAI telemetry", () => {
? span.status.endTime >= http.status.endTime
: false,
).toBeTrue()
expect(traceparent).toBeUndefined()
expect(traceparent?.split("-")[1]).toBe(http?.traceId)
expect(traceparent?.split("-")[2]).toBe(http?.spanId)
}),
)
@ -303,7 +304,7 @@ describe("GenAI telemetry", () => {
yield* Effect.useSpan("ambient", () =>
Effect.all(
[
LLMHttpTelemetry.stream(HttpClientRequest.post("https://example.test/path"), Stream.empty).pipe(
LLMHttpTelemetry.stream(HttpClientRequest.post("https://example.test/path"), () => Stream.empty).pipe(
Stream.runDrain,
),
LLMWebSocketTelemetry.stream("wss://example.test/path", Stream.empty).pipe(Stream.runDrain),
@ -524,7 +525,7 @@ describe("GenAI telemetry", () => {
}),
)
it.live("does not mutate provider request headers", () =>
it.live("propagates the transport span to provider requests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const received: { traceparent?: string; b3?: string } = {}
@ -564,9 +565,10 @@ describe("GenAI telemetry", () => {
)
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(http).toBeDefined()
expect(received.traceparent).toBeUndefined()
expect(received.b3).toBeUndefined()
const traceparent = received.traceparent?.split("-")
expect(traceparent?.[1]).toBe(http?.traceId)
expect(traceparent?.[2]).toBe(http?.spanId)
expect(received.b3).toStartWith(`${http?.traceId}-${http?.spanId}-`)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),