diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index 10f4e1087a3..3bb7f1ea1ac 100644 --- a/packages/shared/src/relayTracing.test.ts +++ b/packages/shared/src/relayTracing.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; +import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; -import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts"; +import { + makeRelayClientTracingLayer, + RelayClientTracer, + withRelayClientTracing, +} from "./relayTracing.ts"; function collectingTracer(spans: Array): Tracer.Tracer { return Tracer.make({ @@ -54,4 +61,44 @@ describe("withRelayClientTracing", () => { expect(userSpans).toEqual(["relay.operation"]); }), ); + + it.effect("preserves nested error causes in exported relay spans", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const httpClientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), + ); + const tracingLayer = makeRelayClientTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "relay-traces", + tracesToken: "public-ingest-token", + }, + { + serviceName: "relay-test", + runtime: "test", + client: "test", + }, + ).pipe(Layer.provide(httpClientLayer)); + const rootCause = new Error("relay socket closed"); + const failure = new Error("relay request failed", { cause: rootCause }); + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("relay.failed-operation"), + withRelayClientTracing, + Effect.exit, + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const payload = new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array); + expect(payload).toContain("relay request failed"); + expect(payload).toContain("relay socket closed"); + }), + ), + ); + }); }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index ecf035534ef..1259984ea3c 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -41,7 +41,16 @@ export const withRelayClientTracing = ( ), ); -function traceSafeError(value: unknown): Error { +function cleanTraceStack(error: Error): string { + const stack = error.stack ?? `${error.name}: ${error.message}`; + const lines = stack.split("\n"); + const effectFrameIndex = lines.findIndex( + (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line), + ); + return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); +} + +function traceSafeError(value: unknown, seen = new WeakSet()): Error { const message = value instanceof Error ? value.message @@ -51,12 +60,19 @@ function traceSafeError(value: unknown): Error { typeof value.message === "string" ? value.message : String(value); - const error = new Error(message); + + let cause: Error | undefined; + if (typeof value === "object" && value !== null && !seen.has(value)) { + seen.add(value); + if ("cause" in value && value.cause !== undefined) { + cause = traceSafeError(value.cause, seen); + } + } + + const error = new Error(message, cause ? { cause } : undefined); if (value instanceof Error) { error.name = value.name; - if (value.stack !== undefined) { - error.stack = value.stack; - } + error.stack = cleanTraceStack(value); } else if ( typeof value === "object" && value !== null && @@ -65,6 +81,9 @@ function traceSafeError(value: unknown): Error { ) { error.name = value.name; } + if (cause) { + error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; + } return error; }