authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-26 01:34:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-26 01:34:55-07:00
log294dd123410c77eead2810a052c31fb0cfd3e0c1
tree6ac2f8127870256cc8a2df2836c457885c55416e
parent6f97b0f66a51fbafabd54a5616da8827ebdf6020
signature Signed by PGP key B5690EEEBB952194

fix(observability): the renderer trace proxy stops tracing itself (#13761)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 124 insertions(+), 9 deletions(-)

apps/server/src/http.ts+22-8
......@@ -311,9 +311,12 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
311311
312312class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
313313 readonly cause: unknown;
314 readonly bodyJson: OtlpTracer.TraceData;
315314}> {}
316315
316// Renderers export up to once a second while they have spans buffered, so
317// tracing this proxy would add more server spans than it forwards.
318// withTracerEnabled(false) drops the handler's spans, including the forward.
319// untracedRequestsLayer drops the HTTP server span.
317320export const otlpTracesProxyRouteLayer = HttpRouter.add(
318321 "POST",
319322 OTLP_TRACES_PROXY_PATH,
......@@ -330,15 +333,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
330333
331334 yield* Effect.try({
332335 try: () => decodeOtlpTraceRecords(bodyJson),
333 catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
336 catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }),
334337 }).pipe(
335338 Effect.flatMap((records) => browserTraceCollector.record(records)),
336 Effect.catch((cause) =>
337 Effect.logWarning("Failed to decode browser OTLP traces", {
338 cause,
339 bodyJson,
340 }),
341 ),
339 Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })),
342340 );
343341
344342 if (otlpTracesUrl === undefined) {
......@@ -369,9 +367,25 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
369367 EnvironmentInternalError: HttpServerRespondable.toResponse,
370368 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
371369 }),
370 Effect.withTracerEnabled(false),
372371 ),
373372);
374373
374const UNTRACED_REQUEST_PATHS: ReadonlySet<string> = new Set([OTLP_TRACES_PROXY_PATH]);
375
376// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts
377// before routing, so a route handler cannot skip it. TracerDisabledWhen is one
378// predicate for the whole server and the last layer to provide it wins, so
379// makeRoutesLayer provides this one last. Add paths here instead of providing
380// TracerDisabledWhen again; server.test.ts fails if a later layer replaces it.
381// The query string is ignored, as in routing.
382export const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => {
383 const queryIndex = request.url.indexOf("?");
384 return UNTRACED_REQUEST_PATHS.has(
385 queryIndex === -1 ? request.url : request.url.slice(0, queryIndex),
386 );
387});
388
375389export const assetRouteLayer = HttpRouter.add(
376390 "GET",
377391 `${ASSET_ROUTE_PREFIX}/*`,
apps/server/src/server.test.ts+50
......@@ -5772,6 +5772,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
57725772 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
57735773 );
57745774
5775 it.effect("does not trace browser OTLP trace exports on the server", () =>
5776 Effect.gen(function* () {
5777 const spanNames: Array<string> = [];
5778 const forwardedUrls: Array<string> = [];
5779 yield* buildAppUnderTest({
5780 config: { otlpTracesUrl: "http://collector.test/v1/traces" },
5781 layers: {
5782 httpClient: HttpClient.make((request) =>
5783 Effect.sync(() => {
5784 forwardedUrls.push(request.url);
5785 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }));
5786 }),
5787 ),
5788 },
5789 }).pipe(
5790 Effect.provideService(
5791 Tracer.Tracer,
5792 Tracer.make({
5793 span: (options) => {
5794 spanNames.push(options.name);
5795 return new Tracer.NativeSpan(options);
5796 },
5797 }),
5798 ),
5799 );
5800 const cookie = yield* getAuthenticatedSessionCookieHeader();
5801 spanNames.length = 0;
5802
5803 // The query string must not bring back the HTTP server span.
5804 for (const url of ["/api/observability/v1/traces", "/api/observability/v1/traces?x=1"]) {
5805 const response = yield* HttpClient.post(url, {
5806 headers: { cookie, "content-type": "application/json" },
5807 body: yield* HttpBody.json({ resourceSpans: [] }),
5808 });
5809 assert.equal(response.status, 204);
5810 }
5811
5812 assert.deepEqual(forwardedUrls, [
5813 "http://collector.test/v1/traces",
5814 "http://collector.test/v1/traces",
5815 ]);
5816 assert.deepEqual(spanNames, []);
5817
5818 // Other routes keep their HTTP server span.
5819 const session = yield* HttpClient.get("/api/auth/session", { headers: { cookie } });
5820 assert.equal(session.status, 200);
5821 assert.include(spanNames, "http.server GET");
5822 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5823 );
5824
57755825 it.effect("routes websocket rpc server.upsertKeybinding", () =>
57765826 Effect.gen(function* () {
57775827 const rule: KeybindingRule = {
apps/server/src/server.ts+3
......@@ -33,6 +33,7 @@ import {
3333 staticAndDevRouteLayer,
3434 browserApiCorsLayer,
3535 httpCompressionLayer,
36 untracedRequestsLayer,
3637} from "./http.ts";
3738import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
3839import { fixPath } from "./os-jank.ts";
......@@ -605,6 +606,8 @@ export const makeRoutesLayer = Layer.mergeAll(
605606 websocketRpcRouteLayer,
606607 ),
607608 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
609 // Last, so no route layer can replace the server's one TracerDisabledWhen.
610 untracedRequestsLayer,
608611).pipe(
609612 // Both transports consume the same service instance, so caches single-flight across clients
610613 // and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
packages/shared/src/observability.test.ts+48
......@@ -16,6 +16,7 @@ import * as Tracer from "effect/Tracer";
1616import {
1717 causeErrorTag,
1818 compactTraceAttributes,
19 decodeOtlpTraceRecords,
1920 errorTag,
2021 makeLocalFileTracer,
2122 makeTraceSink,
......@@ -138,6 +139,53 @@ describe("truncateTraceAttributes", () => {
138139 });
139140});
140141
142describe("decodeOtlpTraceRecords", () => {
143 it("clamps oversized renderer span and event attributes", () => {
144 const long = "x".repeat(2_000);
145 const clamped = `${"x".repeat(500)}…[truncated]`;
146 const [record] = decodeOtlpTraceRecords({
147 resourceSpans: [
148 {
149 resource: { attributes: [], droppedAttributesCount: 0 },
150 scopeSpans: [
151 {
152 scope: { name: "effect" },
153 spans: [
154 {
155 traceId: "11111111111111111111111111111111",
156 spanId: "2222222222222222",
157 parentSpanId: undefined,
158 name: "client.span",
159 kind: 1,
160 startTimeUnixNano: "1000000",
161 endTimeUnixNano: "2000000",
162 attributes: [{ key: "payload", value: { stringValue: long } }],
163 droppedAttributesCount: 0,
164 events: [
165 {
166 name: "log",
167 timeUnixNano: "1500000",
168 attributes: [{ key: "effect.cause", value: { stringValue: long } }],
169 droppedAttributesCount: 0,
170 },
171 ],
172 droppedEventsCount: 0,
173 status: { code: 1 },
174 links: [],
175 droppedLinksCount: 0,
176 },
177 ],
178 },
179 ],
180 },
181 ],
182 });
183
184 assert.equal(record?.attributes["payload"], clamped);
185 assert.equal(record?.events[0]?.attributes["effect.cause"], clamped);
186 });
187});
188
141189describe("observability", () => {
142190 it("normalizes circular arrays, maps, and sets without recursing forever", () => {
143191 const array: Array<unknown> = ["alpha"];
packages/shared/src/observability.ts+1-1
......@@ -668,7 +668,7 @@ function decodeAttributes(
668668 entries[attribute.key] = decodeValue(attribute.value);
669669 }
670670
671 return compactTraceAttributes(entries);
671 return truncateTraceAttributes(compactTraceAttributes(entries));
672672}
673673
674674function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {