Visitar URL original
feat(core): tag shell, tool and model-stream spans with session, agent and command by kitlangton · Pull Request #62806 · anomalyco/opencode · GitHub
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
8 changes: 7 additions & 1 deletion packages/core/src/session/runner/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,13 @@ export const make = Effect.gen(function* () {
const pendingStartSnapshot = yield* snapshots.capture().pipe(Effect.forkScoped)
const started = yield* Clock.currentTimeMillis
let firstOutputAt: number | undefined
yield* Effect.annotateCurrentSpan({
const modelAttributes = {
"gen_ai.provider.name": input.model.ref.providerID,
"gen_ai.request.model": input.model.ref.id,
}
yield* Effect.annotateCurrentSpan({
...modelAttributes,
"gen_ai.agent.name": input.agent,
...(input.retryAttempt === undefined ? {} : { "opencode.retry.attempt": input.retryAttempt }),
})
const publisher = createLLMEventPublisher(bus, {
Expand Down Expand Up @@ -110,7 +114,9 @@ export const make = Effect.gen(function* () {
// Provider and tool fibers retain per-source order without a shared writer queue.
// A local execution starts only after its Tool.Called publication completes.
// Read to the end, not just the finish event, so the next request can reuse this response.
// The span covers only the provider stream, so model time separates from tools, which run in this consumer.
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
Stream.withSpan("LLM.stream", { attributes: modelAttributes }),
Stream.runForEach((event) =>
Effect.gen(function* () {
if (publisher.record().providerFailed) return
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/subagent-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const make: Effect.Effect<Interface, never, Session.Service | Job.Service | Scop
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
}).pipe(
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
// The global context drops the caller's span annotations; delivery is the parent session's work.
Effect.annotateSpans("session.id", recovery.parentSessionID),
Effect.forkIn(scope, { startImmediately: true }),
Effect.setContext(context),
)
Expand Down
37 changes: 34 additions & 3 deletions packages/core/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.No
const EXITED_LIMIT = 25
export const RETENTION = Duration.days(7)
export const DIRECTORY = "shell"
const SPAN_COMMAND_LIMIT = 200

type Info = Shell.Info
type CreateInput = Shell.CreateInput & {
Expand Down Expand Up @@ -182,7 +183,7 @@ const layer = () =>
})

const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* annotate((yield* require(id)).info)
yield* removeCommand(id)
})

Expand All @@ -193,15 +194,22 @@ const layer = () =>
})

const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
const { info } = yield* require(id)
yield* annotate(info)
return info
})

const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
const command = yield* require(id)
yield* annotate(command.info)
const info = yield* Deferred.await(command.done)
yield* annotate(info)
return info
})

const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const command = yield* require(id)
yield* annotate(command.info)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
Expand Down Expand Up @@ -242,11 +250,13 @@ const layer = () =>
})

const result = Effect.fn("Shell.result")(function* (started: Shell.Info) {
yield* annotate(started)
const info = yield* wait(started.id).pipe(
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ ...started, status: "killed" as const, time: { ...started.time, completed: Date.now() } }),
),
)
yield* annotate(info)
const capture = yield* Effect.gen(function* () {
const limits = Config.latest(yield* config.entries(), "tool_output")
const maxLines = limits?.max_lines ?? ToolOutput.MAX_LINES
Expand Down Expand Up @@ -322,6 +332,7 @@ const layer = () =>
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
yield* annotate(info)

// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
Expand Down Expand Up @@ -486,6 +497,26 @@ const layer = () =>
}),
)

// Spans carry only the command text, truncated; never env, stdin or output. Shells outlive the turn that started
// them, so the session comes from the shell's own metadata rather than the caller's span annotations.
function annotate(info: Info) {
const sessionID = info.metadata.sessionID
return Effect.annotateCurrentSpan({
"opencode.shell.id": info.id,
"opencode.shell.command": spanCommand(info.command),
...(typeof sessionID === "string" ? { "session.id": sessionID } : {}),
...(info.metadata.background === true ? { "opencode.shell.background": true } : {}),
...(info.status === "running" ? {} : { "opencode.shell.status": info.status }),
...(info.exit === undefined ? {} : { "process.exit.code": info.exit }),
})
}

// Strict OTLP JSON parsers reject a lone surrogate, which a cut through an emoji would leave.
function spanCommand(command: string) {
if (command.length <= SPAN_COMMAND_LIMIT) return command.toWellFormed()
return `${command.slice(0, SPAN_COMMAND_LIMIT).toWellFormed()}…`
}

// Moves past UTF-8 continuation bytes (at most 3) so a byte cut never splits a character.
function charBoundary(bytes: Buffer, offset: number) {
const limit = Math.min(bytes.length, offset + 3)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const layer = Layer.effect(
"gen_ai.tool.name": name,
"gen_ai.tool.call.id": context.id,
"session.id": context.sessionID,
"gen_ai.agent.name": context.agent,
})
const execution = yield* execute(tool, input, context).pipe(
Effect.map((value) => ({ value })),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/tool/plugin/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ export const Plugin = {

return yield* Deferred.await(settled)
}).pipe(
// Shell spans, including the job that waits for a backgrounded command, name the call that started them.
Effect.annotateSpans({
"gen_ai.tool.call.id": context.id,
...(input.background === true ? { "opencode.shell.background": true } : {}),
}),
Effect.map((output) => toolResult(output, finalTimeout)),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
Expand Down
9 changes: 9 additions & 0 deletions packages/core/test/session-runner-recorded.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ describe("SessionRunnerLLM recorded", () => {
)
const attempt = turn.find((span) => span.name === "SessionStep.attempt")
expect(Object.fromEntries(attempt?.attributes ?? [])).toMatchObject({
"gen_ai.agent.name": "build",
"gen_ai.provider.name": "openai",
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.response.finish_reasons": ["stop"],
Expand All @@ -271,6 +272,14 @@ describe("SessionRunnerLLM recorded", () => {
"opencode.usage.cost_usd": 0,
})
expect(attempt?.attributes.get("opencode.llm.first_output_ms")).toBeNumber()
// The provider stream has its own span under the attempt, so model time splits from tool time.
const stream = turn.find((span) => span.name === "LLM.stream")
expect(Option.getOrUndefined(stream!.parent)?.spanId).toBe(attempt!.spanId)
expect(Object.fromEntries(stream!.attributes)).toMatchObject({
"gen_ai.provider.name": "openai",
"gen_ai.request.model": "gpt-4o-mini",
})
expect(stream!.status._tag).toBe("Ended")
}).pipe(Effect.withSpan("fixture.request")),
)
})
45 changes: 44 additions & 1 deletion packages/core/test/session-shell.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream, Tracer } from "effect"
import { Bus } from "@opencode/core/bus"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Location } from "@opencode/core/location"
Expand Down Expand Up @@ -75,6 +75,18 @@ const setup = Effect.gen(function* () {
return { tmp, session, created, shell, control, execution }
})

const recordSpans = () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
return { spans, tracer }
}

const log = (session: Session.Interface, sessionID: Session.ID, follow = false) =>
session.log({ sessionID, follow }).pipe(Stream.filter((event) => !Bus.isSynced(event)))

Expand Down Expand Up @@ -327,6 +339,37 @@ describe("Session.shell", () => {
)
}

it.live("names the session, a truncated command and the exit on shell spans outside any turn", () =>
Effect.gen(function* () {
const { spans, tracer } = recordSpans()
const fixture = yield* setup
// The emoji's two UTF-16 units sit at indexes 199 and 200, across the 200-unit cut.
const command = `exit 3 # ${"x".repeat(190)}😀${"x".repeat(100)}`
const truncated = `exit 3 # ${"x".repeat(190)}\uFFFD…`
yield* fixture.session.shell({ sessionID: fixture.created.id, command }).pipe(Effect.withTracer(tracer))

const create = spans.find((span) => span.name === "Shell.create")
const result = spans.find((span) => span.name === "Shell.result")
const shellID = create?.attributes.get("opencode.shell.id")
expect(shellID).toBeString()
expect(truncated.isWellFormed()).toBe(true)
expect(Object.fromEntries(create?.attributes ?? [])).toEqual({
"opencode.shell.id": shellID,
"opencode.shell.command": truncated,
"session.id": fixture.created.id,
"opencode.shell.background": true,
})
expect(Object.fromEntries(result?.attributes ?? [])).toEqual({
"opencode.shell.id": shellID,
"opencode.shell.command": truncated,
"session.id": fixture.created.id,
"opencode.shell.background": true,
"opencode.shell.status": "exited",
"process.exit.code": 3,
})
}),
)

it.live("admits a spawn failure without waking the model before failing the caller", () =>
Effect.gen(function* () {
const fixture = yield* setup
Expand Down
42 changes: 41 additions & 1 deletion packages/core/test/tool-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { realpathSync, watch } from "node:fs"
import os from "os"
import path from "path"
import { describe, expect } from "bun:test"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream, Tracer } from "effect"
import { Money } from "@opencode/schema/money"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
Expand Down Expand Up @@ -1503,6 +1503,46 @@ describe("ShellTool", () => {
{ timeout: 15_000 },
)

it.live("names the tool call and background on the spans of the shell it starts", () =>
withTempDir((tmp) => {
reset()
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
yield* executeTool(registry, call({ command: bodyExitCommand }, "call-traced"))
yield* executeTool(registry, call({ command: helloCommand, background: true }, "call-traced-background"))
}),
).pipe(
Effect.withTracer(tracer),
Effect.andThen(
Effect.sync(() => {
const attributes = (name: string, callID: string) =>
spans
.filter((span) => span.name === name && span.attributes.get("gen_ai.tool.call.id") === callID)
.map((span) => Object.fromEntries(span.attributes))
expect(attributes("Shell.create", "call-traced")).toEqual([
expect.objectContaining({ "session.id": sessionID, "opencode.shell.command": bodyExitCommand }),
])
expect(attributes("Shell.create", "call-traced")[0]).not.toHaveProperty("opencode.shell.background")
expect(attributes("Shell.result", "call-traced")).toEqual([
expect.objectContaining({ "session.id": sessionID, "process.exit.code": 7 }),
])
expect(attributes("Shell.create", "call-traced-background")).toEqual([
expect.objectContaining({ "session.id": sessionID, "opencode.shell.background": true }),
])
}),
),
)
}),
)

it.live("returns the shell id for a background command", () =>
withTempDir((tmp) => {
reset()
Expand Down
Loading