authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-09-18 13:52:38-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 10:52:38-07:00
logbd5dc58c65b87bfd2222e4697c4742b48e639ea4
treea2d246d140c8809c65e5dc6f43a62afdf17f92eb
parentccad9f69aae7465c4ad4ea169d273f61ffac1eb5
signature Signed by PGP key B5690EEEBB952194

fix(web): client spans reach the trace proxy again (#12332)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

4 files changed, 64 insertions(+), 5 deletions(-)

apps/web/src/lib/runtime.test.ts created+28
......@@ -0,0 +1,28 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Tracer from "effect/Tracer";
4
5import * as ClientTracer from "../observability/clientTracer";
6import { runtime } from "./runtime";
7
8describe("web runtime", () => {
9 it("routes client spans to the exporter client tracing configured", async () => {
10 const exported: Array<string> = [];
11 ClientTracer.setDelegate(
12 Tracer.make({
13 span(options) {
14 exported.push(options.name);
15 return new Tracer.NativeSpan(options);
16 },
17 }),
18 );
19
20 try {
21 await runtime.runPromise(Effect.void.pipe(Effect.withSpan("client.work")));
22 } finally {
23 ClientTracer.setDelegate(null);
24 }
25
26 expect(exported).toEqual(["client.work"]);
27 });
28});
apps/web/src/lib/runtime.ts+3
......@@ -11,6 +11,7 @@ import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
1111import { browserCryptoLayer } from "../cloud/dpop";
1212import { managedRelayClientLayer } from "../cloud/managedRelayLayer";
1313import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";
14import * as ClientTracer from "../observability/clientTracer";
1415
1516function configuredRelayUrl(): string {
1617 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
......@@ -29,6 +30,7 @@ type RuntimeLayerSource =
2930 | typeof browserCryptoLayer
3031 | typeof Socket.layerWebSocketConstructorGlobal
3132 | typeof relayTracingLayer
33 | typeof ClientTracer.layer
3234 | ReturnType<typeof managedRelayClientLayer>;
3335
3436const primaryHttpRuntime = ManagedRuntime.make(
......@@ -56,6 +58,7 @@ const runtimeLayer = Layer.mergeAll(
5658 httpClientLayer,
5759 browserCryptoLayer,
5860 Socket.layerWebSocketConstructorGlobal,
61 ClientTracer.layer,
5962 relayTracingLayer,
6063 managedRelayClientLayer(configuredRelayUrl()).pipe(
6164 Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)),
apps/web/src/observability/clientTracer.ts created+29
......@@ -0,0 +1,29 @@
1import * as Layer from "effect/Layer";
2import * as Tracer from "effect/Tracer";
3
4let delegate: Tracer.Tracer | null = null;
5
6/**
7 * Points the installed tracer at the exporter `configureClientTracing` just built,
8 * or at nothing while no exporter is configured.
9 */
10export function setDelegate(next: Tracer.Tracer | null): void {
11 delegate = next;
12}
13
14export function hasDelegate(): boolean {
15 return delegate !== null;
16}
17
18/**
19 * Installed once when the client runtime is built, before any exporter exists, so
20 * client spans keep flowing to whatever exporter is configured later on.
21 */
22export const layer = Layer.succeed(
23 Tracer.Tracer,
24 Tracer.make({
25 span(options) {
26 return delegate?.span(options) ?? new Tracer.NativeSpan(options);
27 },
28 }),
29);
apps/web/src/observability/clientTracing.ts+4-5
......@@ -2,13 +2,13 @@ import * as Exit from "effect/Exit";
22import * as Layer from "effect/Layer";
33import * as ManagedRuntime from "effect/ManagedRuntime";
44import * as Scope from "effect/Scope";
5import * as Tracer from "effect/Tracer";
65import { HttpClient } from "effect/unstable/http";
76import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
87
98import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
109import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
1110import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
11import * as ClientTracer from "./clientTracer";
1212import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
1313import { isElectron } from "../env";
1414import { APP_VERSION } from "~/branding";
......@@ -30,7 +30,6 @@ const delegateRuntimeLayer = Layer.mergeAll(
3030 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
3131);
3232
33let activeDelegate: Tracer.Tracer | null = null;
3433let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null;
3534let activeScope: Scope.Closeable | null = null;
3635let activeConfigKey: string | null = null;
......@@ -54,7 +53,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
5453 const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS);
5554 const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;
5655
57 if (activeConfigKey === nextConfigKey && activeDelegate !== null) {
56 if (activeConfigKey === nextConfigKey && ClientTracer.hasDelegate()) {
5857 return;
5958 }
6059
......@@ -64,7 +63,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
6463 const previousRuntime = activeRuntime;
6564 const previousScope = activeScope;
6665
67 activeDelegate = null;
66 ClientTracer.setDelegate(null);
6867 activeRuntime = null;
6968 activeScope = null;
7069
......@@ -106,7 +105,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
106105 return;
107106 }
108107
109 activeDelegate = delegateResult.value;
108 ClientTracer.setDelegate(delegateResult.value);
110109 activeRuntime = runtime;
111110 activeScope = scope;
112111}