diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index bd3e0c1217..0e4343f916 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process" export default Runtime.handler( Commands.commands.serve, - Effect.fn("cli.serve")(function* (input) { + Effect.fnUntraced(function* (input) { if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined")) return yield* ServerProcess.run({ mode: input.service ? "service" : input.stdio ? "stdio" : "default", diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index ae4e1a4b61..efd5a38b32 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -24,13 +24,14 @@ export type Options = { readonly port?: number } -export const run = Effect.fn("cli.server-process.run")((options: Options) => - processEffect(options).pipe( +// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace. +export const run = Effect.fnUntraced(function* (options: Options) { + return yield* processEffect(options).pipe( Effect.provide(Updater.layer), Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))), Effect.provide(NodeServices.layer), - ), -) + ) +}) const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 800c100a7f..ec375f14cd 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -10,6 +10,7 @@ import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerRe import { createServer } from "node:http" import { ServerAuth } from "./auth" import { authorizedRequest } from "./middleware/authorization" +import { withoutParentSpan } from "./request-tracing" import { createRoutes } from "./routes" import { ServerInfo } from "./server-info" import { Status } from "./service-status" @@ -39,7 +40,10 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: }) const bound = yield* listen(options) const application = yield* Ref.make(Option.none()) - yield* bound.http.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger) + // Request fibers may continue inbound trace context, but must not inherit the server startup parent. + yield* bound.http + .serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger) + .pipe(withoutParentSpan) if (options.service) yield* options.service.onListen(bound.http.address) const parentScope = yield* Scope.Scope diff --git a/packages/server/src/request-tracing.ts b/packages/server/src/request-tracing.ts new file mode 100644 index 0000000000..fa17f59152 --- /dev/null +++ b/packages/server/src/request-tracing.ts @@ -0,0 +1,6 @@ +import { Context, Effect, Scope, Tracer } from "effect" + +export const withoutParentSpan = (effect: Effect.Effect) => + effect.pipe( + Effect.updateContext((context: Context.Context) => Context.omit(Tracer.ParentSpan)(context)), + ) diff --git a/packages/server/test/request-tracing.test.ts b/packages/server/test/request-tracing.test.ts new file mode 100644 index 0000000000..94a37f759a --- /dev/null +++ b/packages/server/test/request-tracing.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test" +import { Effect, Option, Scope, Tracer } from "effect" +import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { withoutParentSpan } from "../src/request-tracing" + +test("requests ignore ambient parents and continue inbound trace context", async () => { + const spans: Tracer.NativeSpan[] = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + }, + }) + const app = Effect.gen(function* () { + yield* Scope.Scope + return yield* HttpMiddleware.tracer(Effect.succeed(HttpServerResponse.empty())) + }) + const request = (traceparent?: string) => + app.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/trace", { + headers: traceparent === undefined ? undefined : { traceparent }, + }), + ), + ), + withoutParentSpan, + ) + + await Effect.runPromise( + Effect.gen(function* () { + yield* request() + yield* request() + yield* request("00-11111111111111111111111111111111-2222222222222222-01") + yield* Effect.yieldNow + }).pipe(Effect.withSpan("fixture.lifecycle"), Effect.provideService(Tracer.Tracer, tracer), Effect.scoped), + ) + + const requests = spans.filter((span) => span.kind === "server") + expect(requests).toHaveLength(3) + expect(requests[0]?.traceId).not.toBe(requests[1]?.traceId) + expect(Option.getOrUndefined(requests[0]?.parent ?? Option.none())).toBeUndefined() + expect(Option.getOrUndefined(requests[1]?.parent ?? Option.none())).toBeUndefined() + expect(requests[2]?.traceId).toBe("11111111111111111111111111111111") + expect(Option.getOrUndefined(requests[2]?.parent ?? Option.none())?.spanId).toBe("2222222222222222") +})