Skip to content
Open
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
14 changes: 12 additions & 2 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))

Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/effect/observability.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 }
52 changes: 51 additions & 1 deletion packages/core/test/effect/observability.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
})
})
})
5 changes: 3 additions & 2 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>,
})
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })
Expand Down
Loading