Skip to content
Merged
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/sdk-effect-5xx-response-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@maple-dev/effect-sdk": patch
---

A 5xx server span keeps its `HttpServerErrorResponse` exception on Effect 4.0.2 and later, which now fails the span itself with an `HttpServerError` for any 5xx response. Without this, those responses were grouped as one generic `HttpServerError` issue.
11 changes: 7 additions & 4 deletions apps/ai/src/worker/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ import { makeFetch } from "./http"
*/
const ExportedSpan = Schema.Struct({
name: Schema.String,
kind: Schema.optionalKey(Schema.Finite),
attributes: Schema.Array(
Schema.Struct({ key: Schema.String, value: Schema.Record(Schema.String, Schema.Unknown) }),
),
})
type ExportedSpan = typeof ExportedSpan.Type
/** OTLP `SPAN_KIND_SERVER`. Effect names server spans by method alone, so kind is what marks one. */
const SERVER_KIND = 2
const decodeExportedTraces = Schema.decodeUnknownSync(
Schema.Struct({
resourceSpans: Schema.optionalKey(
Expand Down Expand Up @@ -59,7 +62,7 @@ const capturedServerSpans = Effect.acquireRelease(
decodeExportedTraces(JSON.parse(String(init?.body ?? "{}"))).resourceSpans ?? [],
)
.flatMap((resource) => resource.scopeSpans.flatMap((scope) => scope.spans))
.filter((span) => span.name.startsWith("http.server ")),
.filter((span) => span.kind === SERVER_KIND),
),
)

Expand Down Expand Up @@ -126,21 +129,21 @@ describe("the maple-ai Worker through alchemy's bridge", () => {

assert.deepStrictEqual(health, {
status: 200,
span: "http.server GET",
span: "GET",
ordinal: undefined,
ageMs: undefined,
headers: { ordinal: undefined, ageMs: undefined },
})
assert.deepStrictEqual(first, {
status: 200,
span: "http.server POST",
span: "POST",
ordinal: "1",
ageMs: "0",
headers: { ordinal: "1", ageMs: "0" },
})
assert.deepStrictEqual(failed, {
status: 503,
span: "http.server POST",
span: "POST",
ordinal: "2",
ageMs: "250",
headers: { ordinal: "2", ageMs: "250" },
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/worker-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ const ExportedAttribute = Schema.Struct({
})
const ExportedSpan = Schema.Struct({
name: Schema.String,
kind: Schema.optionalKey(Schema.Finite),
status: Schema.Struct({ code: Schema.optionalKey(Schema.Finite) }),
attributes: Schema.Array(ExportedAttribute),
events: Schema.optionalKey(
Schema.Array(Schema.Struct({ name: Schema.String, attributes: Schema.Array(ExportedAttribute) })),
),
})
type ExportedSpan = typeof ExportedSpan.Type
/** OTLP `SPAN_KIND_SERVER`. Effect names server spans by method alone, so kind is what marks one. */
const SERVER_KIND = 2
const ExportedTraces = Schema.Struct({
resourceSpans: Schema.optionalKey(
Schema.Array(
Expand Down Expand Up @@ -79,7 +82,7 @@ const serverSpans = (recorded: ReadonlyArray<RecordedRequest>): Array<ExportedSp
.filter((request) => request.url.endsWith("/v1/traces"))
.flatMap((request) => decodeExportedTraces(JSON.parse(request.body ?? "{}")).resourceSpans ?? [])
.flatMap((resource) => resource.scopeSpans.flatMap((scope) => scope.spans))
.filter((span) => span.name.startsWith("http.server "))
.filter((span) => span.kind === SERVER_KIND)

const logBodies = (recorded: ReadonlyArray<RecordedRequest>): Array<string> =>
recorded
Expand Down
5 changes: 4 additions & 1 deletion apps/electric-sync/src/worker-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
*/
const ExportedSpan = Schema.Struct({
name: Schema.String,
kind: Schema.optionalKey(Schema.Finite),
status: Schema.Struct({
code: Schema.optionalKey(Schema.Finite),
message: Schema.optionalKey(Schema.String),
Expand All @@ -31,6 +32,8 @@ const ExportedSpan = Schema.Struct({
),
})
type ExportedSpan = typeof ExportedSpan.Type
/** OTLP `SPAN_KIND_SERVER`. Effect names server spans by method alone, so kind is what marks one. */
const SERVER_KIND = 2

const ExportedTraces = Schema.Struct({
resourceSpans: Schema.optionalKey(
Expand Down Expand Up @@ -106,7 +109,7 @@ const event = (path: string, app: ReturnType<typeof appLayer> = appLayer()) =>
return {
status: response.status,
flushedBeforeClose,
server: spans.filter((span) => span.name === "http.server GET"),
server: spans.filter((span) => span.kind === SERVER_KIND),
}
}).pipe(Effect.scoped)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ function SystemBlock({
rendered={body.rendered}
raw={raw}
onRawChange={(next) => next !== raw && onToggleRow(rawKey)}
className="self-start"
/>
{/* Copies the prompt as captured, not its rendering. */}
<CopyButton value={row.text} label="system prompt" className="-my-1 shrink-0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,12 @@ function SystemMessageRow({ message }: { message: SpanMessage }) {
proseClassName="text-sm"
/>
</div>
<ViewSwitch rendered={body.rendered} raw={raw} onRawChange={setRaw} />
<ViewSwitch
rendered={body.rendered}
raw={raw}
onRawChange={setRaw}
className="self-start"
/>
{/* Copies the instructions as captured, not their rendering. */}
<CopyButton value={text} label="system message" className="-my-1 shrink-0" />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,9 @@ describe("warehouse spans follow the database conventions", () => {
return { spans, tracer }
}
const okFetch: typeof fetch = async () => new Response('{"data":[]}')
// By kind and method, not name: Effect names it by method alone, and the database span is a client too.
const isHttpClientSpan = (span: Tracer.NativeSpan) =>
span.kind === "client" && span.attributes.has("http.request.method")

it.effect("emits one database span per query and no http.client span beneath it", () => {
const layer = buildLayer(createTestDb(trackedDbs))
Expand All @@ -1398,9 +1401,11 @@ describe("warehouse spans follow the database conventions", () => {
context: "spanShape",
}),
).pipe(Effect.withTracer(tracer))
const names = spans.map((span) => span.name)
assert.include(names, "WarehouseQueryService.executeSql")
assert.isFalse(names.some((name) => name.startsWith("http.client")))
assert.include(
spans.map((span) => span.name),
"WarehouseQueryService.executeSql",
)
assert.isFalse(spans.some(isHttpClientSpan))
}).pipe(Effect.provide(layer), Effect.provideService(FetchHttpClient.Fetch, okFetch))
})

Expand All @@ -1414,7 +1419,7 @@ describe("warehouse spans follow the database conventions", () => {
okFetch,
)
yield* client.sql(parseStatement("SELECT 1 FORMAT JSON")).pipe(Effect.withTracer(tracer))
assert.isTrue(spans.some((span) => span.name === "http.client POST"))
assert.isTrue(spans.some(isHttpClientSpan))
}),
)
})
Expand Down
32 changes: 32 additions & 0 deletions packages/effect-sdk/src/shared/flushable-tracer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as ErrorReporter from "effect/ErrorReporter"
import { HttpApiSchemaError } from "effect/http-api/HttpApiError"
import * as HttpServerError from "effect/http/HttpServerError"
import * as HttpServerRequest from "effect/http/HttpServerRequest"
import * as HttpServerResponse from "effect/http/HttpServerResponse"
import { makeSpanBuffer } from "./flushable-tracer.js"

// A benign error flagged exactly the way Effect's RouteNotFound is.
Expand Down Expand Up @@ -254,6 +255,37 @@ describe("makeSpanBuffer rendered 5xx responses", () => {
}),
)

it.effect("treats Effect's own failure for a 5xx response as the rendered 5xx", () =>
Effect.gen(function* () {
const buffer = makeSpanBuffer()
const request = HttpServerRequest.fromWeb(new Request("http://localhost/api/sync/shape"))
const error = new HttpServerError.HttpServerError({
reason: new HttpServerError.ResponseError({
request,
response: HttpServerResponse.empty({ status: 500 }),
}),
})
yield* Effect.annotateCurrentSpan({
"http.request.method": "GET",
"url.path": "/api/sync/shape",
"http.response.status_code": 500,
}).pipe(
Effect.andThen(Effect.fail(error)),
Effect.withSpan("GET", { kind: "server" }),
Effect.provide(buffer.tracerLayer),
Effect.ignore,
)
const [span] = buffer.drain()
assert.strictEqual(span!.status.message, "HTTP 500 (GET /api/sync/shape)")
const exceptions = span!.events.filter((event) => event.name === "exception")
assert.strictEqual(exceptions.length, 1)
assert.deepStrictEqual(
exceptions[0]!.attributes.find((attribute) => attribute.key === "exception.type")?.value,
{ stringValue: "HttpServerErrorResponse" },
)
}),
)

it.effect("keeps an exception the handler recorded itself instead of relabelling it", () =>
Effect.gen(function* () {
const buffer = makeSpanBuffer()
Expand Down
17 changes: 16 additions & 1 deletion packages/effect-sdk/src/shared/flushable-tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,21 @@ const renderedServerError = (self: SpanImpl): number | undefined => {
return Number.isInteger(code) && code >= 500 ? code : undefined
}

// Effect 4.0.2+ fails a 5xx server span itself, with an `HttpServerError` whose reason is
// `ResponseError`. That names nothing the rendered 5xx did not, so it takes the same road.
const isResponseFailure = (exit: Exit.Exit<unknown, unknown>): boolean => {
if (exit._tag === "Success") return true
const [reason, ...rest] = exit.cause.reasons
return (
rest.length === 0 &&
reason !== undefined &&
Cause.isFailReason(reason) &&
Predicate.isTagged(reason.error, "HttpServerError") &&
Predicate.hasProperty(reason.error, "reason") &&
Predicate.isTagged(reason.error.reason, "ResponseError")
)
}

const makeOtlpSpan = (self: SpanImpl, anticipatedErrorIdentifiers?: ReadonlySet<string>): OtlpSpan => {
const status = self.status as ExtractTag<Tracer.SpanStatus, "Ended">
const attributes = OtlpResource.entriesToAttributes(self.attributes.entries())
Expand All @@ -269,7 +284,7 @@ const makeOtlpSpan = (self: SpanImpl, anticipatedErrorIdentifiers?: ReadonlySet<
}))

let otelStatus: Status
const serverError = status.exit._tag === "Success" ? renderedServerError(self) : undefined
const serverError = isResponseFailure(status.exit) ? renderedServerError(self) : undefined
if (serverError !== undefined) {
const method = self.attributes.get("http.request.method")
const path = self.attributes.get("url.path")
Expand Down
Loading