Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/lib/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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)),
);
});
3 changes: 3 additions & 0 deletions apps/web/src/lib/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,7 @@ type RuntimeLayerSource =
| typeof browserCryptoLayer
| typeof Socket.layerWebSocketConstructorGlobal
| typeof relayTracingLayer
| typeof ClientTracingLive
| ReturnType<typeof managedRelayClientLayer>;

export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer);
Expand Down Expand Up @@ -58,6 +60,7 @@ const runtimeLayer = Layer.mergeAll(
httpClientLayer,
browserCryptoLayer,
Socket.layerWebSocketConstructorGlobal,
ClientTracingLive,
relayTracingLayer,
managedRelayClientLayer(configuredRelayUrl()).pipe(
Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)),
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/observability/clientTracer.ts
Original file line number Diff line number Diff line change
@@ -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);
},
}),
);
20 changes: 5 additions & 15 deletions apps/web/src/observability/clientTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +30,6 @@ const delegateRuntimeLayer = Layer.mergeAll(
Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
);

let activeDelegate: Tracer.Tracer | null = null;
let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null;
let activeScope: Scope.Closeable | null = null;
let activeConfigKey: string | null = null;
Expand All @@ -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<void> {
if (config.exportIntervalMs === undefined && activeConfigKey !== null) {
return pendingConfiguration;
Expand All @@ -63,7 +53,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS);
const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;

if (activeConfigKey === nextConfigKey && activeDelegate !== null) {
if (activeConfigKey === nextConfigKey && hasClientTracerDelegate()) {
return;
}

Expand All @@ -73,7 +63,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
const previousRuntime = activeRuntime;
const previousScope = activeScope;

activeDelegate = null;
setClientTracerDelegate(null);
activeRuntime = null;
activeScope = null;

Expand Down Expand Up @@ -115,7 +105,7 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
return;
}

activeDelegate = delegateResult.value;
setClientTracerDelegate(delegateResult.value);
activeRuntime = runtime;
activeScope = scope;
}
Expand All @@ -135,7 +125,7 @@ async function disposeTracerRuntime(
export async function __resetClientTracingForTests() {
configurationGeneration++;
activeConfigKey = null;
activeDelegate = null;
setClientTracerDelegate(null);
pendingConfiguration = Promise.resolve();

const runtime = activeRuntime;
Expand Down
51 changes: 51 additions & 0 deletions docs/fork/0021-a-trace-includes-the-client-that-started-it.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading