From b2b30413893ae107bc2201e070fdcfa4a1afa7df Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 21 Aug 2026 04:11:25 -0400 Subject: [PATCH 1/2] fix(web): the client half of a trace no longer disappears The client connection rewrite dropped the tracer install, so client spans were created, handed to the server as a parent id, and then thrown away. Every client-initiated trace pointed at a parent that no backend had ever seen. Signed-off-by: Yordis Prieto --- apps/web/src/lib/runtime.test.ts | 18 +++++++++++++ apps/web/src/lib/runtime.ts | 3 +++ apps/web/src/observability/clientTracer.ts | 29 +++++++++++++++++++++ apps/web/src/observability/clientTracing.ts | 20 ++++---------- 4 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/runtime.test.ts create mode 100644 apps/web/src/observability/clientTracer.ts diff --git a/apps/web/src/lib/runtime.test.ts b/apps/web/src/lib/runtime.test.ts new file mode 100644 index 000000000000..0d8ca9a29d8b --- /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 3836d2a39169..421b3cbdc118 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 000000000000..24fb9bbf49c9 --- /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 2d07e218e85b..02d58aee881f 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 Date: Fri, 21 Aug 2026 04:18:50 -0400 Subject: [PATCH 2/2] docs(fork): record the client trace divergence Signed-off-by: Yordis Prieto --- ...ace-includes-the-client-that-started-it.md | 51 +++++++++++++++++++ docs/fork/README.md | 2 + 2 files changed, 53 insertions(+) create mode 100644 docs/fork/0021-a-trace-includes-the-client-that-started-it.md diff --git a/docs/fork/0021-a-trace-includes-the-client-that-started-it.md b/docs/fork/0021-a-trace-includes-the-client-that-started-it.md new file mode 100644 index 000000000000..1deba53fd97b --- /dev/null +++ b/docs/fork/0021-a-trace-includes-the-client-that-started-it.md @@ -0,0 +1,51 @@ +# 0021: A trace includes the client that started it + +- PR: [TrogonStack/t3code#34](https://github.com/TrogonStack/t3code/pull/34) +- Status: active + +## What you can do now + +- Read a trace from the click that started it. Work you begin in the app now + appears in the same trace as the server work it caused, so the path from an + action to its result is one story instead of a fragment of one. +- Tell a slow app from a slow machine. Time the client spends before a request + ever leaves is now visible next to the time the server spent on it, which is + the first question worth asking when something feels slow and the server + looks fine. +- Stop chasing parents that do not exist. Server work used to point at a client + step that never arrived anywhere, so traces read as though something had gone + missing in transit. Nothing was lost in transit; the client step simply never + left. +- Get it on the desktop app too, on the same trace destination you already + configured. There is nothing new to turn on. + +## Why + +The app has produced its own trace steps for a long time, and it has always +told the server which step to attach its work to. What it never did was send +those steps anywhere. The result was the most expensive kind of wrong: traces +that looked complete, arrived on time, and described only half of what happened, +with a root that pointed at something no backend had ever been given. + +That gap is worst exactly where tracing earns its keep. Turn latency is a +question about the whole path, and a trace that starts at the server can only +ever answer the second half of it. Anyone reading one had to know, from +outside the tool, that the missing half was missing rather than empty. + +This was not a decision anyone made. Client tracing was built, wired up, and +then quietly unwired by a later change to how the app builds itself, and the +part that went missing was the only part that had no test. It has been off ever +since, without a warning, a log line, or an empty panel to hint at it. + +## Upstream considerations + +This belongs upstream and should be easy for them to take: it restores behavior +upstream built and intended, changes no API, and adds nothing that is specific +to this fork. The divergence disappears the moment upstream reconnects it, and +this entry goes with it. + +The rebase burden is small but has a sharp edge. The trace destination has to be +attached where the app assembles its shared services, and a sync that rewrites +that assembly has to bring the attachment along. That is precisely how it was +lost the first time, so it now fails a test when it goes missing instead of +failing silently. diff --git a/docs/fork/README.md b/docs/fork/README.md index 7b249f64e044..cae0dafbef6e 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -57,3 +57,5 @@ Each entry uses these sections: active, [#32](https://github.com/TrogonStack/t3code/pull/32) - **0020** [Server logs reach your collector](./0020-server-logs-reach-your-collector.md) active, [#33](https://github.com/TrogonStack/t3code/pull/33) +- **0021** [A trace includes the client that started it](./0021-a-trace-includes-the-client-that-started-it.md) + active, [#34](https://github.com/TrogonStack/t3code/pull/34)