diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d454..b2d573bc4dec 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,6 +24,7 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" +import { injectTraceContext } from "./effect/observability" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -104,8 +105,17 @@ export const make = Effect.gen(function* () { return path.resolve(opts.cwd) }) - const env = (opts: ChildProcess.CommandOptions) => - opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env + const env = (opts: ChildProcess.CommandOptions) => { + if (opts.env === undefined && !opts.extendEnv) { + // Caller relies on default env inheritance. Only synthesize an env object + // when there's an active trace context to inject — otherwise leave it + // undefined so node passes through process.env unchanged. + const injected = injectTraceContext({ ...globalThis.process.env }) + return injected.TRACEPARENT ? injected : undefined + } + const merged = opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env! + return injectTraceContext(merged) + } const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => Stream.isStream(x) ? "pipe" : x diff --git a/packages/core/src/effect/observability.ts b/packages/core/src/effect/observability.ts index 0203079abe1e..28f219ac919a 100644 --- a/packages/core/src/effect/observability.ts +++ b/packages/core/src/effect/observability.ts @@ -1,3 +1,4 @@ +import { context as otelContext, trace, type Context } from "@opentelemetry/api" import { Effect, Layer, Logger } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" @@ -104,4 +105,22 @@ export const layer = !base }), ) -export const Observability = { enabled, layer } +// Injects the W3C trace context for the given (or active) OTel context into +// an env object using the OTel env-carrier variable names (TRACEPARENT, +// TRACESTATE). Subprocess instrumentation that follows the spec will then +// adopt this trace as its parent. No-op when no valid span context is active. +export function injectTraceContext( + env: NodeJS.ProcessEnv, + ctx: Context = otelContext.active(), +): NodeJS.ProcessEnv { + const sc = trace.getSpan(ctx)?.spanContext() + if (!sc || !trace.isSpanContextValid(sc)) return env + const flags = (sc.traceFlags ?? 0).toString(16).padStart(2, "0") + const out: NodeJS.ProcessEnv = { ...env, TRACEPARENT: `00-${sc.traceId}-${sc.spanId}-${flags}` } + const tracestate = sc.traceState?.serialize() + if (tracestate) out.TRACESTATE = tracestate + else delete out.TRACESTATE + return out +} + +export const Observability = { enabled, layer, injectTraceContext } diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 50ea23f89463..282a978d7b8c 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { resource } from "@opencode-ai/core/effect/observability" +import { context, ROOT_CONTEXT, trace, TraceFlags } from "@opentelemetry/api" +import { injectTraceContext, resource } from "@opencode-ai/core/effect/observability" const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES const opencodeClient = process.env.OPENCODE_CLIENT @@ -44,3 +45,52 @@ describe("resource", () => { expect(resource().attributes["service.instance.id"]).not.toBe("override") }) }) + +describe("injectTraceContext", () => { + const traceId = "0af7651916cd43dd8448eb211c80319c" + const spanId = "b7ad6b7169203331" + + const ctxFor = (sc: { traceId: string; spanId: string; traceFlags: number }) => + trace.setSpan(ROOT_CONTEXT, trace.wrapSpanContext(sc)) + + test("leaves env unchanged when no active span", () => { + const env = { FOO: "bar" } + expect(injectTraceContext(env, ROOT_CONTEXT)).toEqual({ FOO: "bar" }) + }) + + test("injects TRACEPARENT from the given OTel context", () => { + const env = injectTraceContext({ FOO: "bar" }, ctxFor({ traceId, spanId, traceFlags: TraceFlags.SAMPLED })) + expect(env.FOO).toBe("bar") + expect(env.TRACEPARENT).toBe(`00-${traceId}-${spanId}-01`) + }) + + test("encodes unsampled trace flags as 00", () => { + expect(injectTraceContext({}, ctxFor({ traceId, spanId, traceFlags: TraceFlags.NONE })).TRACEPARENT).toBe( + `00-${traceId}-${spanId}-00`, + ) + }) + + test("ignores invalid span contexts", () => { + expect( + injectTraceContext({ FOO: "bar" }, ctxFor({ traceId: "0".repeat(32), spanId, traceFlags: 1 })), + ).toEqual({ FOO: "bar" }) + }) + + test("overwrites any stale TRACEPARENT inherited from the env", () => { + const env = injectTraceContext( + { TRACEPARENT: "00-stale-stale-00" }, + ctxFor({ traceId, spanId, traceFlags: TraceFlags.SAMPLED }), + ) + expect(env.TRACEPARENT).toBe(`00-${traceId}-${spanId}-01`) + }) + + test("falls back to the global active context when ctx is omitted", () => { + // Without a registered context manager, the global active context is ROOT, + // so injection should be a no-op rather than throw. + expect(injectTraceContext({ FOO: "bar" })).toEqual({ FOO: "bar" }) + // Sanity: context.with does not propagate without a context manager. + context.with(ctxFor({ traceId, spanId, traceFlags: TraceFlags.SAMPLED }), () => { + expect(injectTraceContext({}).TRACEPARENT).toBeUndefined() + }) + }) +}) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index db43412f73d2..adac588e40e5 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -31,6 +31,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { injectTraceContext } from "@opencode-ai/core/effect/observability" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 @@ -425,11 +426,11 @@ export const layer = Layer.effect( command: cmd, args, cwd, - env: { + env: injectTraceContext({ ...process.env, ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), ...mcp.environment, - }, + }) as Record, }) transport.stderr?.on("data", (chunk: Buffer) => { log.info(`mcp stderr: ${chunk.toString()}`, { key })