diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index f5d0a3a369910..1fb7aaed524a8 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -5,6 +5,7 @@ import { type ServerConfigStreamEvent, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -17,6 +18,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Clock from "effect/Clock"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as TokenStore from "../authorization/tokenStore.ts"; @@ -1561,12 +1563,23 @@ describe("EnvironmentSupervisor", () => { it.effect("releases and reconnects a relay session when credentials change", () => Effect.gen(function* () { + const relaySpans: Array = []; + const relayTracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + relaySpans.push(span); + return span; + }, + }); const harness = yield* makeHarness({ prepare: () => Effect.succeed(preparedFor(RELAY_TARGET)), }); const supervisor = yield* EnvironmentSupervisor.make(RELAY_ENTRY, { initiallyDesired: true, - }).pipe(Effect.provide(harness.dependencies)); + }).pipe( + Effect.provide(harness.dependencies), + Effect.provideService(RelayClientTracer, Option.some(relayTracer)), + ); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("credentials-changed"); @@ -1577,6 +1590,13 @@ describe("EnvironmentSupervisor", () => { expect(yield* Ref.get(harness.sessionCount)).toBe(2); expect(yield* Ref.get(harness.releaseCount)).toBe(1); + const attempts = relaySpans.filter((span) => span.name === "relay.connection.attempt"); + expect(attempts).toHaveLength(2); + for (const span of attempts) { + expect(span.attributes.get("environment.id")).toBe(RELAY_TARGET.environmentId); + expect(span.attributes.get("environment.target.kind")).toBe(RELAY_TARGET._tag); + expect(span.attributes.has("environment.label")).toBe(false); + } }), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index b01f73880ab7d..5a071f8f63a0f 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -528,8 +528,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( let attemptSpan = Option.none(); const traced = Effect.gen(function* () { attemptSpan = Option.some(yield* Effect.currentSpan.pipe(Effect.orDie)); - yield* annotateTarget(target); yield* Effect.annotateCurrentSpan({ + "environment.id": target.environmentId, + "environment.target.kind": target._tag, "connection.attempt": attempt, "connection.generation": generation, "connection.retry.failure_count": Option.match(pendingRetry, { diff --git a/packages/shared/src/relayTracing.test.ts b/packages/shared/src/relayTracing.test.ts index ada768d83992b..f5b45e58c27ba 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 Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; -import { RelayClientTracer, withLocalTracing, withRelayClientTracing } from "./relayTracing.ts"; +import { + nonInterferingTracer, + RelayClientTracer, + withLocalTracing, + withRelayClientTracing, +} from "./relayTracing.ts"; function collectingTracer(spans: Array): Tracer.Tracer { return Tracer.make({ @@ -41,6 +48,45 @@ describe("withRelayClientTracing", () => { ); }); +describe("nonInterferingTracer", () => { + it.effect("exports failures without their messages", () => + Effect.gen(function* () { + const exits: Array> = []; + const delegate = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + exits.push(exit); + }; + return span; + }, + }); + const failure = Object.assign( + new Error("studio-mac could not establish a WebSocket connection.", { + cause: new Error("connect ECONNREFUSED studio-mac.local"), + }), + { name: "ConnectionTransientError" }, + ); + + yield* Effect.fail(failure).pipe( + Effect.withSpan("relay.connection.attempt"), + Effect.withTracer(nonInterferingTracer(delegate)), + Effect.ignore, + ); + + const exit = exits[0]; + if (exit === undefined || !Exit.isFailure(exit)) throw new Error("expected a failed span"); + const exported = Cause.prettyErrors(exit.cause); + expect(exported.map((error) => error.name)).toEqual(["ConnectionTransientError"]); + const text = exported.map((error) => `${error.message}\n${error.stack}`).join("\n"); + expect(text).toContain("Caused by: Error"); + expect(text).not.toContain("studio-mac"); + }), + ); +}); + describe("withLocalTracing", () => { it.effect("keeps local work inside a relay span off the product tracer", () => Effect.gen(function* () { diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index bd00f0ad04a84..6195a8c2832e5 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -67,25 +67,25 @@ export const withLocalTracing = (effect: Effect.Effect): Effec ), ); -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), +// Error messages can carry machine names (environment labels) and paths, so +// relay spans export only the error name and its stack frames. +function traceFrames(error: Error): Array { + const lines = (error.stack ?? "").split("\n"); + const headerLines = error.message.split("\n").length; + const frames = lines.slice(0, headerLines).join("\n").endsWith(error.message) + ? lines.slice(headerLines) + : lines; + const effectFrameIndex = frames.findIndex((line) => + /(?:Generator\.next|~effect\/Effect)/.test(line), ); - return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n"); + return effectFrameIndex < 0 ? frames : frames.slice(0, effectFrameIndex); } function traceSafeError(value: unknown, seen = new WeakSet()): Error { - const message = - value instanceof Error - ? value.message - : typeof value === "object" && - value !== null && - "message" in value && - typeof value.message === "string" - ? value.message - : String(value); + const name = + typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" + ? value.name + : "Error"; let cause: Error | undefined; if (typeof value === "object" && value !== null && !seen.has(value)) { @@ -95,20 +95,11 @@ function traceSafeError(value: unknown, seen = new WeakSet()): Error { } } - const error = new Error(message, cause ? { cause } : undefined); - if (value instanceof Error) { - error.name = value.name; - error.stack = cleanTraceStack(value); - } else if ( - typeof value === "object" && - value !== null && - "name" in value && - typeof value.name === "string" - ) { - error.name = value.name; - } + const error = new Error(name, cause ? { cause } : undefined); + error.name = name; + error.stack = [name, ...(value instanceof Error ? traceFrames(value) : [])].join("\n"); if (cause) { - error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`; + error.stack = `${error.stack}\nCaused by: ${cause.stack}`; } return error; } @@ -132,7 +123,7 @@ function traceSafeExit(exit: Exit.Exit): Exit.Exit