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
30 changes: 22 additions & 8 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,12 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(

class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
readonly cause: unknown;
readonly bodyJson: OtlpTracer.TraceData;
}> {}

// Renderers export up to once a second while they have spans buffered, so
// tracing this proxy would add more server spans than it forwards.
// withTracerEnabled(false) drops the handler's spans, including the forward.
// untracedRequestsLayer drops the HTTP server span.
export const otlpTracesProxyRouteLayer = HttpRouter.add(
"POST",
OTLP_TRACES_PROXY_PATH,
Expand All @@ -330,15 +333,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(

yield* Effect.try({
try: () => decodeOtlpTraceRecords(bodyJson),
catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }),
}).pipe(
Effect.flatMap((records) => browserTraceCollector.record(records)),
Effect.catch((cause) =>
Effect.logWarning("Failed to decode browser OTLP traces", {
cause,
bodyJson,
}),
),
Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })),
);

if (otlpTracesUrl === undefined) {
Expand Down Expand Up @@ -369,9 +367,25 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
EnvironmentInternalError: HttpServerRespondable.toResponse,
EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
}),
Effect.withTracerEnabled(false),
),
);

const UNTRACED_REQUEST_PATHS: ReadonlySet<string> = new Set([OTLP_TRACES_PROXY_PATH]);

// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts
// before routing, so a route handler cannot skip it. TracerDisabledWhen is one
// predicate for the whole server and the last layer to provide it wins, so
// makeRoutesLayer provides this one last. Add paths here instead of providing
// TracerDisabledWhen again; server.test.ts fails if a later layer replaces it.
// The query string is ignored, as in routing.
export const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => {
const queryIndex = request.url.indexOf("?");
return UNTRACED_REQUEST_PATHS.has(
queryIndex === -1 ? request.url : request.url.slice(0, queryIndex),
);
});

export const assetRouteLayer = HttpRouter.add(
"GET",
`${ASSET_ROUTE_PREFIX}/*`,
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5772,6 +5772,56 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("does not trace browser OTLP trace exports on the server", () =>
Effect.gen(function* () {
const spanNames: Array<string> = [];
const forwardedUrls: Array<string> = [];
yield* buildAppUnderTest({
config: { otlpTracesUrl: "http://collector.test/v1/traces" },
layers: {
httpClient: HttpClient.make((request) =>
Effect.sync(() => {
forwardedUrls.push(request.url);
return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }));
}),
),
},
}).pipe(
Effect.provideService(
Tracer.Tracer,
Tracer.make({
span: (options) => {
spanNames.push(options.name);
return new Tracer.NativeSpan(options);
},
}),
),
);
const cookie = yield* getAuthenticatedSessionCookieHeader();
spanNames.length = 0;

// The query string must not bring back the HTTP server span.
for (const url of ["/api/observability/v1/traces", "/api/observability/v1/traces?x=1"]) {
const response = yield* HttpClient.post(url, {
headers: { cookie, "content-type": "application/json" },
body: yield* HttpBody.json({ resourceSpans: [] }),
});
assert.equal(response.status, 204);
}

assert.deepEqual(forwardedUrls, [
"http://collector.test/v1/traces",
"http://collector.test/v1/traces",
]);
assert.deepEqual(spanNames, []);

// Other routes keep their HTTP server span.
const session = yield* HttpClient.get("/api/auth/session", { headers: { cookie } });
assert.equal(session.status, 200);
assert.include(spanNames, "http.server GET");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it.effect("routes websocket rpc server.upsertKeybinding", () =>
Effect.gen(function* () {
const rule: KeybindingRule = {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
staticAndDevRouteLayer,
browserApiCorsLayer,
httpCompressionLayer,
untracedRequestsLayer,
} from "./http.ts";
import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts";
import { fixPath } from "./os-jank.ts";
Expand Down Expand Up @@ -605,6 +606,8 @@ export const makeRoutesLayer = Layer.mergeAll(
websocketRpcRouteLayer,
),
McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
// Last, so no route layer can replace the server's one TracerDisabledWhen.
untracedRequestsLayer,
).pipe(
// Both transports consume the same service instance, so caches single-flight across clients
// and mutations observed on WebSocket invalidate patches subsequently read over HTTP.
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Tracer from "effect/Tracer";
import {
causeErrorTag,
compactTraceAttributes,
decodeOtlpTraceRecords,
errorTag,
makeLocalFileTracer,
makeTraceSink,
Expand Down Expand Up @@ -138,6 +139,53 @@ describe("truncateTraceAttributes", () => {
});
});

describe("decodeOtlpTraceRecords", () => {
it("clamps oversized renderer span and event attributes", () => {
const long = "x".repeat(2_000);
const clamped = `${"x".repeat(500)}鈥truncated]`;
const [record] = decodeOtlpTraceRecords({
resourceSpans: [
{
resource: { attributes: [], droppedAttributesCount: 0 },
scopeSpans: [
{
scope: { name: "effect" },
spans: [
{
traceId: "11111111111111111111111111111111",
spanId: "2222222222222222",
parentSpanId: undefined,
name: "client.span",
kind: 1,
startTimeUnixNano: "1000000",
endTimeUnixNano: "2000000",
attributes: [{ key: "payload", value: { stringValue: long } }],
droppedAttributesCount: 0,
events: [
{
name: "log",
timeUnixNano: "1500000",
attributes: [{ key: "effect.cause", value: { stringValue: long } }],
droppedAttributesCount: 0,
},
],
droppedEventsCount: 0,
status: { code: 1 },
links: [],
droppedLinksCount: 0,
},
],
},
],
},
],
});

assert.equal(record?.attributes["payload"], clamped);
assert.equal(record?.events[0]?.attributes["effect.cause"], clamped);
});
});

describe("observability", () => {
it("normalizes circular arrays, maps, and sets without recursing forever", () => {
const array: Array<unknown> = ["alpha"];
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ function decodeAttributes(
entries[attribute.key] = decodeValue(attribute.value);
}

return compactTraceAttributes(entries);
return truncateTraceAttributes(compactTraceAttributes(entries));
}

function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
Expand Down
Loading