diff --git a/apps/web/src/lib/runtime.test.ts b/apps/web/src/lib/runtime.test.ts new file mode 100644 index 00000000000..0d8ca9a29d8 --- /dev/null +++ b/apps/web/src/lib/runtime.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Tracer from "effect/Tracer"; + +import { ClientTracingLive } from "../observability/clientTracer"; +import { runtime } from "./runtime"; + +const readTracer = Effect.service(Tracer.Tracer); + +describe("web runtime", () => { + it.effect("installs the client tracer so client spans reach the trace proxy", () => + Effect.gen(function* () { + const installed = yield* Effect.promise(() => runtime.runPromise(readTracer)); + + expect(installed).toBe(yield* readTracer); + }).pipe(Effect.provide(ClientTracingLive)), + ); +}); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 3836d2a3916..421b3cbdc11 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -11,6 +11,7 @@ import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { browserCryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; +import { ClientTracingLive } from "../observability/clientTracer"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; @@ -29,6 +30,7 @@ type RuntimeLayerSource = | typeof browserCryptoLayer | typeof Socket.layerWebSocketConstructorGlobal | typeof relayTracingLayer + | typeof ClientTracingLive | ReturnType; export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); @@ -58,6 +60,7 @@ const runtimeLayer = Layer.mergeAll( httpClientLayer, browserCryptoLayer, Socket.layerWebSocketConstructorGlobal, + ClientTracingLive, relayTracingLayer, managedRelayClientLayer(configuredRelayUrl()).pipe( Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), diff --git a/apps/web/src/observability/clientTracer.ts b/apps/web/src/observability/clientTracer.ts new file mode 100644 index 00000000000..24fb9bbf49c --- /dev/null +++ b/apps/web/src/observability/clientTracer.ts @@ -0,0 +1,29 @@ +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; + +let delegate: Tracer.Tracer | null = null; + +/** + * Points the installed tracer at the exporter `configureClientTracing` just built, + * or at nothing while no exporter is configured. + */ +export function setClientTracerDelegate(next: Tracer.Tracer | null): void { + delegate = next; +} + +export function hasClientTracerDelegate(): boolean { + return delegate !== null; +} + +/** + * Installed once when the client runtime is built, before any exporter exists, so + * client spans keep flowing to whatever exporter is configured later on. + */ +export const ClientTracingLive = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span(options) { + return delegate?.span(options) ?? new Tracer.NativeSpan(options); + }, + }), +); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 2d07e218e85..02d58aee881 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -2,13 +2,13 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Scope from "effect/Scope"; -import * as Tracer from "effect/Tracer"; import { HttpClient } from "effect/unstable/http"; import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import { hasClientTracerDelegate, setClientTracerDelegate } from "./clientTracer"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; @@ -30,7 +30,6 @@ const delegateRuntimeLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -let activeDelegate: Tracer.Tracer | null = null; let activeRuntime: ManagedRuntime.ManagedRuntime | null = null; let activeScope: Scope.Closeable | null = null; let activeConfigKey: string | null = null; @@ -41,15 +40,6 @@ export interface ClientTracingConfig { readonly exportIntervalMs?: number; } -export const ClientTracingLive = Layer.succeed( - Tracer.Tracer, - Tracer.make({ - span(options) { - return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); - }, - }), -); - export function configureClientTracing(config: ClientTracingConfig = {}): Promise { if (config.exportIntervalMs === undefined && activeConfigKey !== null) { return pendingConfiguration; @@ -63,7 +53,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise