Compare commits

...

11 Commits

Author SHA1 Message Date
starptech 00fb739ad6 refactor(opencode): pass server endpoint to TUI 2026-07-10 00:09:25 +01:00
starptech 9307833c5c chore(observability): merge v2 2026-07-09 17:08:08 +01:00
starptech 2ba0817402 feat(llm): add GenAI operation telemetry metadata 2026-07-09 17:03:52 +01:00
starptech f4e661f874 chore(observability): merge v2 2026-07-09 06:36:36 +02:00
starptech aacec8ea06 feat(llm): propagate trace context to HTTP requests 2026-07-08 23:51:07 +02:00
starptech 18da3deee7 chore(observability): merge v2 2026-07-08 21:36:57 +02:00
starptech 0b9f1a47c6 feat(observability): refine span linkage and telemetry 2026-07-08 21:33:46 +02:00
starptech a2e640aef9 feat(observability): add turn links to session traces 2026-07-08 21:12:27 +02:00
starptech 88173fecd4 chore(opencode): reorder workspace deps 2026-07-08 18:17:37 +02:00
starptech a18e5de4af chore(observability): merge v2 2026-07-08 18:16:17 +02:00
starptech 0271872c09 feat(observability): add v2 genai tracing 2026-07-08 18:02:31 +02:00
60 changed files with 3856 additions and 413 deletions
+1
View File
@@ -307,6 +307,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
operation: "chat",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
auth: Auth.none,
transport: {
+13 -6
View File
@@ -2,16 +2,23 @@ export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "./effect/layer-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Cause, Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
const references = Layer.mergeAll(
Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel()),
Layer.succeed(References.TracerEnabled, false),
Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
Layer.succeed(HttpClient.TracerPropagationEnabled, false),
)
const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
Layer.merge(references),
)
export const layer = Layer.unwrap(
@@ -21,10 +28,10 @@ export const layer = Layer.unwrap(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
Layer.merge(references),
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
return Layer.merge(logs, yield* Otlp.tracingLayer)
}),
).pipe(Layer.catchCause(() => local))
).pipe(Layer.catchCause((cause) => (Cause.hasInterrupts(cause) ? Layer.effectDiscard(Effect.failCause(cause)) : local)))
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
+297
View File
@@ -0,0 +1,297 @@
export * as AgentTelemetry from "./agent"
import type { LLMEvent, Usage } from "@opencode-ai/llm"
import { Cause, Clock, Context, Effect, Exit, Option } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_AGENT_NAME,
ATTR_GEN_AI_CONVERSATION_COMPACTED,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_TOOL_CALL_ID,
ATTR_GEN_AI_TOOL_NAME,
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
ATTR_OPENCODE_AGENT_STEP_INDEX,
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
ATTR_OPENCODE_COMPACTION_REASON,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_LINK_TYPE,
ATTR_OPENCODE_RETRY_ATTEMPT,
ATTR_OPENCODE_RETRY_DELAY_MS,
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
ATTR_OPENCODE_RETRY_DECISION,
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
ATTR_OPENCODE_SESSION_INPUT_COUNT,
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
ATTR_OPENCODE_SESSION_PARENT_ID,
ATTR_OPENCODE_TOOL_OUTCOME,
EVENT_OPENCODE_COMPACTION_COMPLETED,
EVENT_OPENCODE_COMPACTION_FAILED,
EVENT_OPENCODE_COMPACTION_STARTED,
EVENT_OPENCODE_PROVIDER_TOOL_CALLED,
EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED,
EVENT_OPENCODE_RETRY_SCHEDULED,
EVENT_OPENCODE_RETRY_STOPPED,
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
} from "./semconv"
import { SessionTelemetry } from "./session"
export type ModelCallTrigger = "input" | "tool_result" | "retry" | "compaction" | "resume"
export type RetryDecision = "exhausted" | "non_retryable" | "output_started" | "step_limit"
const FailureState = Context.Reference<{ stage?: string } | undefined>("@opencode/AgentTelemetry/FailureState", {
defaultValue: () => undefined,
})
export const currentSpan = Effect.option(Effect.currentSpan).pipe(
Effect.map(Option.getOrUndefined),
Effect.map(findAgentSpan),
)
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
export const invoke = <A, E, R>(
input: { readonly sessionID: string; readonly agent: string; readonly errorType: (cause: unknown) => string },
effect: Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const traceParent = yield* SessionTelemetry.TraceParent
const traceLinks = yield* SessionTelemetry.TraceLinks
const turnLinks = yield* SessionTelemetry.TurnLinks
const previousTurn = turnLinks?.previous()
const failureState: { stage?: string } = {}
return yield* Effect.acquireUseRelease(
Effect.makeSpan(`invoke_agent ${input.agent}`, {
kind: "internal",
parent: traceParent ?? undefined,
root: traceParent === null,
links: previousTurn
? [...traceLinks, { span: previousTurn, attributes: { [ATTR_OPENCODE_LINK_TYPE]: "previous_turn" } }]
: traceLinks,
attributes: {
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
},
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.succeed(undefined),
),
),
(span) =>
span
? Effect.sync(() => turnLinks?.set(span)).pipe(
Effect.andThen(
effect.pipe(
Effect.withParentSpan(span, { captureStackTrace: false }),
Effect.provideService(FailureState, failureState),
Effect.withTracerEnabled(false),
),
),
)
: effect,
(span, exit) => span ? finishSpan(span, exit, failureState, input.errorType) : Effect.void,
)
})
function finishSpan<E>(
span: Span,
exit: Exit.Exit<unknown, E>,
failureState: { stage?: string },
errorType: (cause: unknown) => string,
) {
return observe(
Effect.gen(function* () {
if (Exit.isSuccess(exit)) {
span.end(yield* Clock.currentTimeNanos, exit)
return
}
const canceled = Cause.hasInterruptsOnly(exit.cause)
const type = canceled ? "canceled" : yield* classify(() => errorType(Cause.squash(exit.cause)))
const source = canceled
? "cancellation"
: type.startsWith("provider.")
? "provider"
: type.startsWith("permission.")
? "permission"
: type.startsWith("tool.")
? "tool"
: "session"
span.attribute(ATTR_ERROR_TYPE, type)
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, source)
if (failureState.stage) span.attribute(ATTR_OPENCODE_ERROR_STAGE, failureState.stage)
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
}),
)
}
export const identify = (input: { readonly agent: string; readonly parentSessionID?: string }) =>
withCurrent((span) => {
span.attribute(ATTR_GEN_AI_AGENT_NAME, input.agent)
if (input.parentSessionID) span.attribute(ATTR_OPENCODE_SESSION_PARENT_ID, input.parentSessionID)
})
export const stage = <A, E, R>(name: string, effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const state = yield* FailureState
return yield* effect.pipe(
Effect.onExit((exit) =>
Effect.sync(() => {
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) && state && !state.stage) state.stage = name
}),
),
)
})
export const resetStage = Effect.gen(function* () {
const state = yield* FailureState
if (state) state.stage = undefined
})
export const inputPromoted = (input: { readonly delivery: "steer" | "queue"; readonly count: number }) =>
event(EVENT_OPENCODE_SESSION_INPUT_PROMOTED, {
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: input.delivery,
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: input.count,
})
export const compactionStarted = (reason: "manual" | "automatic") =>
event(EVENT_OPENCODE_COMPACTION_STARTED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
export const compactionCompleted = (reason: "manual" | "automatic") =>
event(EVENT_OPENCODE_COMPACTION_COMPLETED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
export const compactionFailed = (reason: "manual" | "automatic") =>
event(EVENT_OPENCODE_COMPACTION_FAILED, { [ATTR_OPENCODE_COMPACTION_REASON]: reason })
export const modelCall = (input: {
readonly sessionID: string
readonly agent: string
readonly step: number
readonly trigger: ModelCallTrigger
readonly retryAttempt?: number
readonly delivery?: "steer" | "queue"
readonly compacted: boolean
}) => {
let usage: Usage | undefined
const observeEvent = (value: LLMEvent) =>
Effect.gen(function* () {
if (value.type === "tool-call" && value.providerExecuted)
yield* event(EVENT_OPENCODE_PROVIDER_TOOL_CALLED, {
[ATTR_GEN_AI_TOOL_CALL_ID]: value.id,
[ATTR_GEN_AI_TOOL_NAME]: value.name,
})
if (value.type === "tool-result" && value.providerExecuted)
yield* event(EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED, {
[ATTR_GEN_AI_TOOL_CALL_ID]: value.id,
[ATTR_GEN_AI_TOOL_NAME]: value.name,
[ATTR_OPENCODE_TOOL_OUTCOME]: value.result.type === "error" ? "error" : "completed",
})
if ("usage" in value && value.usage !== undefined) usage = value.usage
if (value.type === "finish" && usage) yield* recordUsage(usage)
})
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const agentSpan = yield* currentSpan
const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID)
const observed = effect.pipe(
Effect.annotateSpans({
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
[ATTR_OPENCODE_AGENT_STEP_INDEX]: input.step,
[ATTR_OPENCODE_AGENT_STEP_TRIGGER]: input.trigger,
...(input.retryAttempt === undefined ? {} : { [ATTR_OPENCODE_RETRY_ATTEMPT]: input.retryAttempt }),
...(input.delivery === undefined ? {} : { [ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: input.delivery }),
...(input.compacted ? { [ATTR_GEN_AI_CONVERSATION_COMPACTED]: true } : {}),
...(typeof parentSessionID === "string" ? { [ATTR_OPENCODE_SESSION_PARENT_ID]: parentSessionID } : {}),
}),
)
if (!agentSpan) return yield* observed
return yield* observed.pipe(Effect.withParentSpan(agentSpan, { captureStackTrace: false }))
})
return { observe: observeEvent, run }
}
export const retryScheduled = (input: {
readonly attempt: number
readonly maxAttempts: number
readonly delayMs: number
readonly retryAfterMs?: number
readonly errorType: string
}) =>
event(EVENT_OPENCODE_RETRY_SCHEDULED, {
[ATTR_OPENCODE_RETRY_ATTEMPT]: input.attempt,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: input.maxAttempts,
[ATTR_OPENCODE_RETRY_DELAY_MS]: input.delayMs,
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: input.retryAfterMs === undefined ? "backoff" : "max(backoff,retry_after)",
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
[ATTR_ERROR_TYPE]: input.errorType,
})
export const retryStopped = (input: {
readonly decision: RetryDecision
readonly attempt: number
readonly maxAttempts: number
readonly errorType: string
}) =>
event(EVENT_OPENCODE_RETRY_STOPPED, {
[ATTR_OPENCODE_RETRY_DECISION]: input.decision,
[ATTR_OPENCODE_RETRY_ATTEMPT]: input.attempt,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: input.maxAttempts,
[ATTR_ERROR_TYPE]: input.errorType,
})
function recordUsage(usage: Usage) {
return withCurrent((span) => {
const add = (key: string, value: number | undefined) => {
if (value === undefined) return
const current = span.attributes.get(key)
span.attribute(key, (typeof current === "number" ? current : 0) + value)
}
add(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage.inputTokens)
add(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage.outputTokens)
add(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, usage.cacheReadInputTokens)
add(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, usage.cacheWriteInputTokens)
add(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, usage.reasoningTokens)
})
}
function event(name: string, attributes: Record<string, unknown>) {
return Effect.gen(function* () {
const span = yield* currentSpan
if (!span) return
const time = yield* Clock.currentTimeNanos
yield* observe(Effect.sync(() => span.event(name, time, attributes)))
})
}
function withCurrent(f: (span: Span) => void) {
return Effect.gen(function* () {
const span = yield* currentSpan
if (span) yield* observe(Effect.sync(() => f(span)))
})
}
function findAgentSpan(span: Span | undefined): Span | undefined {
if (!span) return
if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT) return span
const parent = Option.getOrUndefined(span.parent)
return findAgentSpan(parent?._tag === "Span" ? parent : undefined)
}
function classify(f: () => string) {
return Effect.sync(f).pipe(Effect.catchCause(() => Effect.succeed("unknown")))
}
+118
View File
@@ -0,0 +1,118 @@
export * as HttpTelemetry from "./http"
import { Cause, Clock, Effect, Exit, Option } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import {
ATTR_ERROR_TYPE,
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_SERVER_ADDRESS,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
ATTR_URL_PATH,
ATTR_URL_SCHEME,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
} from "./semconv"
import { ToolTelemetry } from "./tool"
import { AgentTelemetry } from "./agent"
export const use = <A, E, R>(
http: HttpClient.HttpClient,
request: HttpClientRequest.HttpClientRequest,
consume: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
validate?: (
response: HttpClientResponse.HttpClientResponse,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
): Effect.Effect<A, E | HttpClientError.HttpClientError, R> =>
Effect.gen(function* () {
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
const state: { responseStatus?: number } = {}
const execute = http.execute(request).pipe(
Effect.flatMap(validate ?? Effect.succeed),
Effect.tap((response) => Effect.sync(() => (state.responseStatus = response.status))),
Effect.flatMap(consume),
)
const parent =
(yield* ToolTelemetry.currentSpan) ??
(yield* AgentTelemetry.currentSpan) ??
Option.getOrUndefined(yield* Effect.option(Effect.currentSpan))
if (!parent) return yield* execute
const url = URL.canParse(request.url) ? new URL(request.url) : undefined
const port = url?.port
? Number(url.port)
: url?.protocol === "https:"
? 443
: url?.protocol === "http:"
? 80
: undefined
const span = yield* Effect.makeSpan(request.method, {
kind: "client",
parent,
attributes: {
[ATTR_HTTP_REQUEST_METHOD]: request.method,
...(url
? {
[ATTR_SERVER_ADDRESS]: url.hostname,
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
[ATTR_URL_FULL]: safeUrl(url),
[ATTR_URL_PATH]: url.pathname,
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
}
: {}),
},
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCause(() => Effect.succeed(undefined)),
)
if (!span) return yield* execute
return yield* execute.pipe(
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
Effect.onExit((exit) =>
observe(
Effect.gen(function* () {
if (state.responseStatus !== undefined)
span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, state.responseStatus)
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) {
span.end(yield* Clock.currentTimeNanos, exit)
return
}
const error = Option.getOrUndefined(Exit.findErrorOption(exit))
const type = HttpClientError.isHttpClientError(error)
? error.reason._tag
: error instanceof Error
? error.name
: "unknown"
const status =
HttpClientError.isHttpClientError(error) && "response" in error.reason
? error.reason.response.status
: undefined
if (status !== undefined) span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
span.attribute(ATTR_ERROR_TYPE, type)
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "transport")
span.attribute(
ATTR_OPENCODE_ERROR_STAGE,
status !== undefined ? "response" : state.responseStatus !== undefined ? "response_stream" : "request",
)
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
}),
),
),
Effect.withParentSpan(span, { captureStackTrace: false }),
Effect.withTracerEnabled(false),
)
})
function safeUrl(url: URL) {
const safe = new URL(url)
safe.username = ""
safe.password = ""
safe.search = ""
safe.hash = ""
return safe.toString()
}
+58 -25
View File
@@ -1,10 +1,17 @@
import { Layer } from "effect"
import { Effect, Layer } from "effect"
import { OtlpLogger } from "effect/unstable/observability"
import {
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
ATTR_OPENCODE_CLIENT,
ATTR_OPENCODE_RUN,
ATTR_SERVICE_INSTANCE_ID,
} from "./semconv"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { runID } from "./shared"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
let installedContextManager: { readonly manager: { disable(): unknown }; references: number } | undefined
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -34,15 +41,16 @@ function resourceAttributes() {
}
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const attributes = resourceAttributes()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...resourceAttributes(),
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.run": runID,
"service.instance.id": runID,
...attributes,
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME] ?? InstallationChannel,
[ATTR_OPENCODE_CLIENT]: Flag.OPENCODE_CLIENT,
[ATTR_OPENCODE_RUN]: runID,
[ATTR_SERVICE_INSTANCE_ID]: runID,
},
}
}
@@ -52,28 +60,53 @@ export function loggers() {
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
}
export async function tracingLayer() {
export const tracingLayer = Effect.gen(function* () {
if (!endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const NodeSdk = yield* Effect.promise(() => import("@effect/opentelemetry/NodeSdk"))
const OTLP = yield* Effect.promise(() => import("@opentelemetry/exporter-trace-otlp-http"))
const SdkBase = yield* Effect.promise(() => import("@opentelemetry/sdk-trace-base"))
const { AsyncLocalStorageContextManager } = yield* Effect.promise(() => import("@opentelemetry/context-async-hooks"))
const { context } = yield* Effect.promise(() => import("@opentelemetry/api"))
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
const manager = new AsyncLocalStorageContextManager()
manager.enable()
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
const contextManager = Layer.effectDiscard(
Effect.acquireRelease(
Effect.sync(() => {
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
if (installedContextManager) {
installedContextManager.references += 1
return { installed: true, manager: installedContextManager.manager }
}
const manager = new AsyncLocalStorageContextManager().enable()
const installed = context.setGlobalContextManager(manager)
if (!installed) manager.disable()
if (installed) installedContextManager = { manager, references: 1 }
return { installed, manager }
}),
({ installed, manager }) =>
Effect.sync(() => {
if (!installed) return
if (installedContextManager?.manager !== manager) return
installedContextManager.references -= 1
if (installedContextManager.references > 0) return
installedContextManager = undefined
context.disable()
manager.disable()
}),
),
}))
}
)
return Layer.merge(
contextManager,
NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
}),
),
})),
)
})
export * as Otlp from "./otlp"
@@ -0,0 +1,58 @@
export * as CoreSemconv from "./semconv"
export const ATTR_DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name"
export const ATTR_ERROR_TYPE = "error.type"
export const ATTR_HTTP_REQUEST_METHOD = "http.request.method"
export const ATTR_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
export const ATTR_SERVER_ADDRESS = "server.address"
export const ATTR_SERVER_PORT = "server.port"
export const ATTR_SERVICE_INSTANCE_ID = "service.instance.id"
export const ATTR_SERVICE_NAMESPACE = "service.namespace"
export const ATTR_URL_FULL = "url.full"
export const ATTR_URL_PATH = "url.path"
export const ATTR_URL_SCHEME = "url.scheme"
export const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name"
export const ATTR_GEN_AI_CONVERSATION_COMPACTED = "gen_ai.conversation.compacted"
export const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
export const ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
export const ATTR_GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"
export const ATTR_GEN_AI_TOOL_NAME = "gen_ai.tool.name"
export const ATTR_GEN_AI_TOOL_TYPE = "gen_ai.tool.type"
export const ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
export const ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
export const ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS = "gen_ai.usage.reasoning.output_tokens"
export const GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL = "execute_tool"
export const GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT = "invoke_agent"
export const ATTR_OPENCODE_AGENT_STEP_INDEX = "opencode.agent.step.index"
export const ATTR_OPENCODE_AGENT_STEP_TRIGGER = "opencode.agent.step.trigger"
export const ATTR_OPENCODE_CLIENT = "opencode.client"
export const ATTR_OPENCODE_COMPACTION_REASON = "opencode.compaction.reason"
export const ATTR_OPENCODE_ERROR_SOURCE = "opencode.error.source"
export const ATTR_OPENCODE_ERROR_STAGE = "opencode.error.stage"
export const ATTR_OPENCODE_LINK_TYPE = "opencode.link.type"
export const ATTR_OPENCODE_RETRY_ATTEMPT = "opencode.retry.attempt"
export const ATTR_OPENCODE_RETRY_DELAY_MS = "opencode.retry.delay_ms"
export const ATTR_OPENCODE_RETRY_DELAY_SOURCE = "opencode.retry.delay_source"
export const ATTR_OPENCODE_RETRY_DECISION = "opencode.retry.decision"
export const ATTR_OPENCODE_RETRY_MAX_ATTEMPTS = "opencode.retry.max_attempts"
export const ATTR_OPENCODE_RUN = "opencode.run"
export const ATTR_OPENCODE_SESSION_INPUT_COUNT = "opencode.session.input.count"
export const ATTR_OPENCODE_SESSION_INPUT_DELIVERY = "opencode.session.input.delivery"
export const ATTR_OPENCODE_SESSION_PARENT_ID = "opencode.session.parent.id"
export const ATTR_OPENCODE_SUBAGENT_AGENT_NAME = "opencode.subagent.agent.name"
export const ATTR_OPENCODE_SUBAGENT_SESSION_ID = "opencode.subagent.session.id"
export const ATTR_OPENCODE_TOOL_OUTCOME = "opencode.tool.outcome"
export const EVENT_OPENCODE_COMPACTION_COMPLETED = "opencode.session.compaction.completed"
export const EVENT_OPENCODE_COMPACTION_FAILED = "opencode.session.compaction.failed"
export const EVENT_OPENCODE_COMPACTION_STARTED = "opencode.session.compaction.started"
export const EVENT_OPENCODE_PROVIDER_TOOL_CALLED = "opencode.provider_tool.called"
export const EVENT_OPENCODE_PROVIDER_TOOL_COMPLETED = "opencode.provider_tool.completed"
export const EVENT_OPENCODE_RETRY_SCHEDULED = "opencode.provider.retry.scheduled"
export const EVENT_OPENCODE_RETRY_STOPPED = "opencode.provider.retry.stopped"
export const EVENT_OPENCODE_SESSION_INPUT_PROMOTED = "opencode.session.input.promoted"
@@ -0,0 +1,68 @@
export * as SessionTelemetry from "./session"
import { Context, Effect, Option } from "effect"
import type { AnySpan, Span, SpanLink } from "effect/Tracer"
import { DisablePropagation, ParentSpan } from "effect/Tracer"
// undefined inherits the ambient parent during resume; null explicitly detaches execution.
export const TraceParent = Context.Reference<AnySpan | null | undefined>("@opencode/SessionTelemetry/TraceParent", {
defaultValue: () => undefined,
})
export const TraceLinks = Context.Reference<ReadonlyArray<SpanLink>>("@opencode/SessionTelemetry/TraceLinks", {
defaultValue: () => [],
})
export const TurnLinks = Context.Reference<
{ readonly previous: () => Span | undefined; readonly set: (span: Span) => void } | undefined
>("@opencode/SessionTelemetry/TurnLinks", { defaultValue: () => undefined })
export function makeExecution<Key>() {
const turnCapacity = 1_024
const parents = new Map<Key, AnySpan | null>()
const links = new Map<Key, ReadonlyArray<SpanLink>>()
const turns = new Map<Key, Span>()
const turnLinks = (key: Key) => ({
previous: () => {
const span = turns.get(key)
if (!span) return
turns.delete(key)
turns.set(key, span)
return span
},
set: (span: Span) => {
turns.delete(key)
turns.set(key, span)
if (turns.size <= turnCapacity) return
const oldest = turns.keys().next()
if (!oldest.done) turns.delete(oldest.value)
},
})
const drain = <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => {
const parent = parents.get(key) ?? null
const observed = effect.pipe(
Effect.provideService(TraceParent, parent),
Effect.provideService(TraceLinks, links.get(key) ?? []),
Effect.provideService(TurnLinks, turnLinks(key)),
)
return parent === null ? observed : observed.pipe(Effect.withParentSpan(parent, { captureStackTrace: false }))
}
const resume = <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const override = yield* TraceParent
const ambient = Option.getOrUndefined(yield* Effect.serviceOption(ParentSpan))
const inherited = ambient && !Context.get(ambient.annotations, DisablePropagation) ? ambient : undefined
const parent = override === null ? null : (override ?? inherited ?? null)
if (!parents.has(key)) {
parents.set(key, parent)
links.set(key, yield* TraceLinks)
}
return yield* effect
})
const settled = (key: Key) =>
Effect.sync(() => {
parents.delete(key)
links.delete(key)
})
return { drain, resume, settled }
}
+139
View File
@@ -0,0 +1,139 @@
export * as ToolTelemetry from "./tool"
import { Cause, Clock, Effect, Exit, Option } from "effect"
import type { Span } from "effect/Tracer"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_AGENT_NAME,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_TOOL_CALL_ID,
ATTR_GEN_AI_TOOL_NAME,
ATTR_GEN_AI_TOOL_TYPE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_LINK_TYPE,
ATTR_OPENCODE_SESSION_PARENT_ID,
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
ATTR_OPENCODE_TOOL_OUTCOME,
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
} from "./semconv"
import { AgentTelemetry } from "./agent"
import { SessionTelemetry } from "./session"
export const currentSpan = Effect.option(Effect.currentSpan).pipe(
Effect.map(Option.getOrUndefined),
Effect.map(findToolSpan),
)
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
export const execute = <A, E, R>(
input: {
readonly sessionID: string
readonly agent: string
readonly call: { readonly id: string; readonly name: string }
},
effect: Effect.Effect<A, E, R>,
errorType: (cause: unknown) => string,
resultErrorType?: (result: A) => string | undefined,
) =>
Effect.gen(function* () {
const toolSpan = yield* currentSpan
const agentSpan = yield* AgentTelemetry.currentSpan
const parent = toolSpan ?? agentSpan
const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID)
const span = yield* Effect.makeSpan(`execute_tool ${input.call.name}`, {
kind: "internal",
parent,
attributes: {
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
[ATTR_GEN_AI_TOOL_NAME]: input.call.name,
[ATTR_GEN_AI_TOOL_TYPE]: "function",
[ATTR_GEN_AI_TOOL_CALL_ID]: input.call.id,
[ATTR_GEN_AI_AGENT_NAME]: input.agent,
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
...(typeof parentSessionID === "string" ? { [ATTR_OPENCODE_SESSION_PARENT_ID]: parentSessionID } : {}),
},
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCause(() => Effect.succeed(undefined)),
)
if (!span) return yield* effect
return yield* effect.pipe(
Effect.tap((settlement) =>
observe(
Effect.gen(function* () {
const type = resultErrorType?.(settlement)
span.attribute(ATTR_OPENCODE_TOOL_OUTCOME, type ? "error" : "completed")
if (!type) return
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "tool")
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "execution")
span.attribute(ATTR_ERROR_TYPE, type)
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
}),
),
),
Effect.onExit((exit) => {
if (span.status._tag === "Ended") return Effect.void
if (Exit.isSuccess(exit))
return observe(
Effect.gen(function* () {
span.end(yield* Clock.currentTimeNanos, exit)
}),
)
const canceled = Cause.hasInterrupts(exit.cause)
return observe(
Effect.gen(function* () {
const type = canceled
? "canceled"
: yield* Effect.sync(() => errorType(Cause.squash(exit.cause))).pipe(
Effect.catchCause(() => Effect.succeed("unknown")),
)
span.attribute(ATTR_OPENCODE_TOOL_OUTCOME, canceled ? "canceled" : "error")
span.attribute(ATTR_OPENCODE_ERROR_SOURCE, canceled ? "cancellation" : "tool")
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "execution")
span.attribute(ATTR_ERROR_TYPE, type)
span.end(yield* Clock.currentTimeNanos, Exit.fail(new Error(type)))
}),
)
}),
Effect.withParentSpan(span, { captureStackTrace: false }),
Effect.withTracerEnabled(false),
)
})
export const child = (input: { readonly agent: string; readonly sessionID: string }) =>
Effect.gen(function* () {
const span = yield* currentSpan
if (span)
yield* observe(
Effect.sync(() => {
span.attribute(ATTR_OPENCODE_SUBAGENT_AGENT_NAME, input.agent)
span.attribute(ATTR_OPENCODE_SUBAGENT_SESSION_ID, input.sessionID)
}),
)
return {
resume: <A, E, R>(effect: Effect.Effect<A, E, R>, background: boolean) =>
effect.pipe(
Effect.provideService(SessionTelemetry.TraceParent, background ? null : span),
Effect.provideService(
SessionTelemetry.TraceLinks,
background && span ? [{ span, attributes: { [ATTR_OPENCODE_LINK_TYPE]: "subagent" } }] : [],
),
),
}
})
function findToolSpan(span: Span | undefined): Span | undefined {
if (!span) return
if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL) return span
const parent = Option.getOrUndefined(span.parent)
return findToolSpan(parent?._tag === "Span" ? parent : undefined)
}
+21 -2
View File
@@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { Cause, Effect, Layer, Schema, Context, Option, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
@@ -43,7 +43,7 @@ import { SkillV2 } from "./skill"
import { Job } from "./job"
import { CommandV2 } from "./command"
import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import type { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
@@ -531,6 +531,17 @@ const layer = Layer.effect(
}
return admitted
}),
).pipe(
Effect.tapCause((cause) =>
Effect.logError("Failed to admit Session prompt", cause).pipe(
Effect.annotateLogs({
operation: "session.prompt",
sessionID: input.sessionID,
...(input.id === undefined ? {} : { messageID: input.id }),
errorType: promptErrorType(cause),
}),
),
),
),
),
command: Effect.fn("V2Session.command")(function* (input) {
@@ -776,6 +787,14 @@ const layer = Layer.effect(
}),
)
function promptErrorType(cause: Cause.Cause<unknown>) {
if (Cause.hasInterruptsOnly(cause)) return "canceled"
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
if (error && typeof error === "object" && "_tag" in error && typeof error._tag === "string") return error._tag
const failure = Cause.squash(cause)
return failure instanceof Error ? failure.name : "unknown"
}
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
+32 -10
View File
@@ -10,6 +10,7 @@ import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
import { toSessionError } from "../to-session-error"
import { UserInterruptedError } from "../error"
import { SessionTelemetry } from "../../observability/session"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
@@ -19,6 +20,11 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?:
return { type: "failed" as const, error: toSessionError(failure) }
}
function errorType(cause: Cause.Cause<unknown>) {
const error = Cause.squash(cause)
return error instanceof Error ? error.name : "unknown"
}
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
const layer = Layer.effect(
SessionExecution.Service,
@@ -26,13 +32,19 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
const telemetry = SessionTelemetry.makeExecution<SessionSchema.ID>()
const reportLifecycle = <A>(sessionID: SessionSchema.ID, phase: string, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
Effect.annotateLogs({ sessionID }),
Effect.annotateLogs({
operation: "session.execution.lifecycle",
phase,
sessionID,
errorType: errorType(cause),
}),
),
),
Effect.asVoid,
@@ -42,25 +54,34 @@ const layer = Layer.effect(
SessionRunner.RunError,
"user" | "shutdown" | "superseded"
>({
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
started: (sessionID) =>
reportLifecycle(sessionID, "started", events.publish(SessionEvent.Execution.Started, { sessionID })),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
const drain = SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
: Effect.logError("Failed to drain Session", cause).pipe(
Effect.annotateLogs({
operation: "session.execution.drain",
sessionID,
errorType: toSessionError(Cause.squash(cause)).type,
}),
),
),
)
return yield* telemetry.drain(sessionID, drain)
}),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
settled: (sessionID, exit, reason) => {
const outcome = terminal(exit, reason)
return reportLifecycle(
sessionID,
outcome.type,
Effect.gen(function* () {
const outcome = terminal(exit, reason)
if (outcome.type === "succeeded") {
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
return
@@ -74,13 +95,14 @@ const layer = Layer.effect(
error: outcome.error,
})
}),
),
).pipe(Effect.ensuring(telemetry.settled(sessionID)))
},
})
return SessionExecution.Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
resume: coordinator.run,
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle,
})
+255 -150
View File
@@ -12,7 +12,7 @@ import {
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, References, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
@@ -51,6 +51,7 @@ import { llmClient } from "../../effect/app-node-platform"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { AgentTelemetry } from "../../observability/agent"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { PluginHooks } from "../../plugin/hooks"
import { PluginSupervisor } from "../../plugin/supervisor"
@@ -205,25 +206,32 @@ const layer = Layer.effect(
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
trigger: AgentTelemetry.ModelCallTrigger,
retryAttempt: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
assistantMessageID?: SessionMessage.ID,
) {
const session = yield* getSession(sessionID)
const session = yield* AgentTelemetry.stage("session", getSession(sessionID))
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
const agent = yield* agents.select(session.agent)
yield* AgentTelemetry.stage("plugin_readiness", plugins.flush)
const agent = yield* AgentTelemetry.stage("agent", agents.select(session.agent))
yield* AgentTelemetry.identify({ agent: agent.id, parentSessionID: session.parentID })
if (!agent.info)
return yield* AgentTelemetry.stage(
"agent",
new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }),
)
const agentInfo = agent.info
if (!agentInfo) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
const checkpoint = yield* InstructionCheckpoint.prepare(
db,
events,
loadInstructions(agent, session.id),
session.id,
const checkpoint = yield* AgentTelemetry.stage(
"instructions",
InstructionCheckpoint.prepare(db, events, loadInstructions(agent, session.id), session.id),
)
let currentStep = step
let currentTrigger = trigger
let inputDelivery: SessionInput.Delivery | undefined
if (promotion) {
let promoted = 0
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
@@ -231,17 +239,28 @@ const layer = Layer.effect(
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
}
if (promoted > 0) currentStep = 1
if (promoted > 0) {
currentStep = 1
currentTrigger = "input"
inputDelivery = promotion
yield* AgentTelemetry.inputPromoted({ delivery: promotion, count: promoted })
}
}
const resolved = yield* models.resolve(session)
const resolved = yield* AgentTelemetry.stage("model_resolution", models.resolve(session))
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const entries = yield* AgentTelemetry.stage(
"history",
SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq),
)
const context = entries.map((entry) => entry.message)
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agentInfo.permissions, model })
: yield* AgentTelemetry.stage(
"tool_materialization",
tools.materialize({ permissions: agentInfo.permissions, model }),
)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -284,12 +303,17 @@ const layer = Layer.effect(
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
// Automatic compaction completed; rebuild the request from compacted history.
if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
if (!(yield* SessionInput.pendingCompaction(db, session.id))) {
const compacted = yield* AgentTelemetry.stage(
"compaction",
compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }),
)
if (compacted) {
yield* AgentTelemetry.compactionCompleted("automatic")
return { _tag: "RestartAfterCompaction", step: currentStep } as const
}
}
const startSnapshot = yield* AgentTelemetry.stage("snapshot", snapshots.capture())
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@@ -306,78 +330,97 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return
}
// A request hook hid this registered tool from the current request. Fail only
// this call durably and continue so the model can react, instead of executing
// a tool that was not advertised. Unregistered tools flow through settle, which
// durably fails them as unknown.
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
needsContinuation = true
yield* publish(
LLMEvent.toolError({
id: event.id,
name: event.name,
message: `Tool is not available for this request: ${event.name}`,
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
const telemetry = AgentTelemetry.modelCall({
sessionID: session.id,
agent: agent.id,
step: currentStep,
trigger: currentTrigger,
retryAttempt: retryAttempt > 0 ? retryAttempt + 1 : undefined,
delivery: inputDelivery,
compacted: context.some((message) => message.type === "compaction"),
})
const providerStream = AgentTelemetry.stage(
"model",
telemetry.run(
llm.stream(hookedRequest).pipe(
Stream.provideService(References.TracerEnabled, true),
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
yield* publish(event)
yield* telemetry.observe(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return
}
// A request hook hid this registered tool from the current request. Fail only
// this call durably and continue so the model can react, instead of executing
// a tool that was not advertised. Unregistered tools flow through settle, which
// durably fails them as unknown.
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
needsContinuation = true
yield* publish(
LLMEvent.toolError({
id: event.id,
name: event.name,
message: `Tool is not available for this request: ${event.name}`,
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
call: event,
}),
settlement.error,
).pipe(
// Terminal cleanup must own failAssistant because the provider may still be streaming another tool input.
Effect.andThen(
settlement.error?.type === "permission.rejected"
? Effect.fail(new StepFailedError({ error: settlement.error }))
: Effect.void,
Effect.flatMap((settlement) =>
AgentTelemetry.stage(
"tool_publication",
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.error,
),
).pipe(
// Terminal cleanup must own failAssistant because the provider may still be streaming another tool input.
Effect.andThen(
settlement.error?.type === "permission.rejected"
? Effect.fail(new StepFailedError({ error: settlement.error }))
: Effect.void,
),
),
),
),
),
),
).pipe(FiberSet.run(toolFibers)),
)
}),
).pipe(FiberSet.run(toolFibers)),
)
}),
),
Effect.ensuring(serialized(publisher.flush())),
),
),
Effect.ensuring(serialized(publisher.flush())),
)
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
@@ -396,15 +439,18 @@ const layer = Layer.effect(
.files({ from: startSnapshot, to: endSnapshot })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
snapshot: endSnapshot,
files,
}),
yield* AgentTelemetry.stage(
"step_publication",
serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
snapshot: endSnapshot,
files,
}),
),
)
})
@@ -434,11 +480,10 @@ const layer = Layer.effect(
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() &&
(agentInfo.steps === undefined || currentStep < agentInfo.steps)
) {
const retryable = SessionRunnerRetry.isRetryable(llmFailure)
const hasOutput = publisher.hasRetryEvidence()
const stepLimited = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
if (retryable && !hasOutput && !stepLimited) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
@@ -446,6 +491,12 @@ const layer = Layer.effect(
step: currentStep,
})
}
yield* AgentTelemetry.retryStopped({
decision: retryable ? (hasOutput ? "output_started" : "step_limit") : "non_retryable",
attempt: retryAttempt + 1,
maxAttempts: SessionRunnerRetry.maxAttempts,
errorType: error.type,
})
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here.
@@ -518,16 +569,17 @@ const layer = Layer.effect(
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
publisher.failAssistant({ type: "tool.result-missing", message: "Provider did not return a tool result" }),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement()
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
!streamInterrupted &&
!toolsInterrupted &&
infraError === undefined &&
!providerFailed &&
!stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
if (stepFailure)
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
@@ -552,6 +604,7 @@ const layer = Layer.effect(
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
trigger: AgentTelemetry.ModelCallTrigger,
) {
// Compaction restarts rebuild the request from compacted history without re-promoting.
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
@@ -559,10 +612,24 @@ const layer = Layer.effect(
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion
let currentStep = step
let currentTrigger = trigger
let retryAttempt = 0
let assistantMessageID: SessionMessage.ID | undefined
while (true) {
const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
AgentTelemetry.resetStage.pipe(
Effect.andThen(
attemptStep(
sessionID,
currentPromotion,
currentStep,
currentTrigger,
retryAttempt,
recoverOverflow,
assistantMessageID,
),
),
),
).pipe(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
@@ -570,22 +637,33 @@ const layer = Layer.effect(
currentStep = error.step + 1
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
currentTrigger = "retry"
retryAttempt += 1
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
})
.pipe(Effect.andThen(Effect.fail(error.cause)))
return AgentTelemetry.retryStopped({
decision: "exhausted",
attempt: retryAttempt,
maxAttempts: SessionRunnerRetry.maxAttempts,
errorType: error.error.type,
}).pipe(
Effect.andThen(
events.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
}),
),
Effect.andThen(Effect.fail(error.cause)),
)
}),
)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
currentTrigger = "compaction"
yield* Effect.yieldNow
currentPromotion = undefined
currentStep = attempt.step
@@ -597,6 +675,7 @@ const layer = Layer.effect(
) {
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
if (!pending) return false
yield* AgentTelemetry.compactionStarted("manual")
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -609,7 +688,10 @@ const layer = Layer.effect(
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
if (Exit.isSuccess(compacted) && compacted.value) {
yield* AgentTelemetry.compactionCompleted("manual")
return true
}
if (Exit.isFailure(compacted)) {
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
if (unsettled)
@@ -619,6 +701,7 @@ const layer = Layer.effect(
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
yield* AgentTelemetry.compactionFailed("manual")
return yield* Effect.failCause(compacted.cause)
}
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
@@ -629,6 +712,7 @@ const layer = Layer.effect(
error: { type: "compaction.failed", message: "Compaction could not start" },
inputID: unsettled.id,
})
yield* AgentTelemetry.compactionFailed("manual")
return true
}),
)
@@ -639,44 +723,65 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
}) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let shouldRun = input.force || hasSteer || hasQueue
while (shouldRun) {
let needsContinuation = true
let step = 1
// Repeat steps while continuation is needed. A step needs continuation only
// when it recorded local tool calls whose results the model has not yet seen;
// a provider error suppresses it. Pending steers also continue the loop so
// interjections are answered before the session goes idle.
while (needsContinuation) {
const result = yield* runStep(input.sessionID, promotion, step)
// Steer/queue promotion inside runStep has already made the pending input a visible
// user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const run = Effect.gen(function* () {
if (yield* SessionInput.pendingCompaction(db, input.sessionID)) {
const compactionAgent = yield* agents.select((yield* getSession(input.sessionID)).agent)
yield* AgentTelemetry.invoke(
{
sessionID: input.sessionID,
agent: compactionAgent.id,
errorType: (cause) => toSessionError(cause).type,
},
AgentTelemetry.stage("compaction", runPendingCompaction(input.sessionID)),
)
}
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
}
if (!input.force && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let trigger: AgentTelemetry.ModelCallTrigger = hasSteer || hasQueue ? "input" : "resume"
let shouldRun = input.force || hasSteer || hasQueue
while (shouldRun) {
const invocationAgent = yield* agents.select((yield* getSession(input.sessionID)).agent)
yield* AgentTelemetry.invoke(
{ sessionID: input.sessionID, agent: invocationAgent.id, errorType: (cause) => toSessionError(cause).type },
Effect.gen(function* () {
let needsContinuation = true
let step = 1
// One agent turn runs from initial input promotion until the Session would become idle.
// Model calls remain individual steps beneath this turn span.
while (needsContinuation) {
const result = yield* runStep(input.sessionID, promotion, step, trigger)
// Steer/queue promotion inside runStep has already made the pending input a visible
// user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
trigger = "tool_result"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
trigger = "tool_result"
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
}
}),
)
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
trigger = hasSteer || hasQueue ? "input" : "resume"
}
})
return yield* run
})
return Service.of({ drain })
+24 -12
View File
@@ -4,11 +4,14 @@ import { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
import { AgentTelemetry } from "../../observability/agent"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export const maxAttempts = 5
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError
readonly assistantMessageID: SessionMessage.ID
@@ -45,7 +48,7 @@ const retryAfter = (failure: RetryableFailure) => {
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.exponential("2 seconds").pipe(
Schedule.take(4),
Schedule.take(maxAttempts - 1),
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
@@ -53,15 +56,24 @@ export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID)
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
metadata.input instanceof RetryableFailure
? events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
})
: Effect.void,
),
Schedule.tap((metadata) => {
const failure = metadata.input
if (!(failure instanceof RetryableFailure)) return Effect.void
return Effect.gen(function* () {
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: failure.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: failure.error,
})
yield* AgentTelemetry.retryScheduled({
attempt: metadata.attempt + 1,
maxAttempts,
delayMs: Duration.toMillis(metadata.duration),
retryAfterMs: retryAfter(failure),
errorType: failure.error.type,
})
})
}),
)
+23 -8
View File
@@ -3,6 +3,8 @@ export * as ExecuteTool from "./execute"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/llm"
import { Effect, Ref, Schema } from "effect"
import { ToolTelemetry } from "../observability/tool"
import { toSessionError } from "../session/to-session-error"
import { definition, make, settle, type AnyTool } from "./tool"
const ExecuteFile = Schema.Struct({
@@ -115,18 +117,31 @@ export const create = (options: {
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const current = options.current(name)
if (!current || current.identity !== registration.identity)
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
const output = yield* settle(
current.tool,
{ type: "tool-call", id: context.toolCallID, name, input },
const state = { stale: false }
const output = yield* ToolTelemetry.execute(
{
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
call: { id: `${context.toolCallID}/${index + 1}`, name },
},
Effect.gen(function* () {
const current = options.current(name)
if (!current || current.identity !== registration.identity) {
state.stale = true
return yield* Effect.fail(toolError(`Stale tool call: ${name}`))
}
return yield* settle(
current.tool,
{ type: "tool-call", id: context.toolCallID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
},
)
}),
(cause) => (state.stale ? "tool.stale" : toSessionError(cause).type),
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)
if (outputFileParts.length > 0)
+22 -9
View File
@@ -16,6 +16,7 @@ import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
import { ToolTelemetry } from "../observability/tool"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
@@ -212,15 +213,27 @@ const registryLayer = Layer.effect(
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
],
settle: (input) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
settle: (input) =>
ToolTelemetry.execute(
input,
Effect.suspend(() => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name)
if (registration) return settleWith(input, registration.identity)
return Effect.succeed({
result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown" as const, message: `Unknown tool: ${input.call.name}` },
})
}),
(cause) => toSessionError(cause).type,
(settlement) => {
if (settlement.error) return settlement.error.type
if (input.call.name !== "execute") return
const structured = settlement.output?.structured
if (typeof structured !== "object" || structured === null || !("error" in structured)) return
return structured.error === true ? "tool.execution" : undefined
},
),
}
}),
})
+3 -1
View File
@@ -5,6 +5,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plu
import { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PluginRuntime } from "../plugin/runtime"
import { ToolTelemetry } from "../observability/tool"
import { PermissionV2 } from "../permission"
import { SessionSchema } from "../session/schema"
import { Tool } from "./tool"
@@ -147,13 +148,14 @@ export const Plugin = {
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
)
const telemetry = yield* ToolTelemetry.child({ agent: input.agent, sessionID: child.id })
const background = input.background === true
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
yield* runtime.session.prompt({ sessionID: child.id, text: input.prompt, resume: false })
yield* runtime.session.resume(child.id)
yield* telemetry.resume(runtime.session.resume(child.id), background)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
+18 -13
View File
@@ -6,6 +6,7 @@ import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { HttpTelemetry } from "../observability/http"
import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool"
@@ -84,7 +85,21 @@ const assertHttpUrl = (url: URL) => {
}
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
HttpTelemetry.use(
http,
request(url, format, userAgent),
(response) =>
Effect.gen(function* () {
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}),
HttpClientResponse.filterStatusOk,
)
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
collectBoundedResponseBody(
@@ -144,18 +159,8 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
const { body, contentType } = yield* Effect.gen(function* () {
const response = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
)
const contentType = response.headers["content-type"] || ""
const mime = mimeFrom(contentType)
if (isImageAttachment(mime))
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
if (!isTextualMime(mime))
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
return { body: yield* collectBody(response), contentType }
}).pipe(
const { body, contentType } = yield* execute(http, input.url, input.format).pipe(
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
Effect.timeoutOrElse({
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
orElse: () => Effect.fail(new Error("Request timed out")),
+10 -6
View File
@@ -7,6 +7,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { makeLocationNode } from "../effect/app-node"
import { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version"
import { HttpTelemetry } from "../observability/http"
import { PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@@ -167,13 +168,16 @@ const callMcp = <F extends Schema.Struct.Fields>(
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.filterStatusOk(http).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
return yield* HttpTelemetry.use(
HttpClient.filterStatusOk(http),
request,
(response) =>
collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
).pipe(Effect.flatMap((body) => parseResponse(body.toString("utf8")))),
)
return yield* parseResponse(body.toString("utf8"))
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
+304 -12
View File
@@ -1,11 +1,32 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger } from "effect"
import {
ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
ATTR_ERROR_TYPE,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_LINK_TYPE,
ATTR_OPENCODE_CLIENT,
ATTR_OPENCODE_RUN,
ATTR_OPENCODE_TOOL_OUTCOME,
ATTR_SERVICE_INSTANCE_ID,
ATTR_SERVICE_NAMESPACE,
ATTR_URL_FULL,
} from "@opencode-ai/core/observability/semconv"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Option, Tracer } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileLogger } from "../../src/observability/logging"
import { resource } from "../../src/observability/otlp"
import { SessionTelemetry } from "../../src/observability/session"
import { HttpTelemetry } from "../../src/observability/http"
import { AgentTelemetry } from "../../src/observability/agent"
import { ToolTelemetry } from "../../src/observability/tool"
import { it } from "../lib/effect"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
@@ -20,8 +41,7 @@ afterEach(() => {
describe("resource", () => {
test("parses and decodes OTEL resource attributes", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES =
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here`
expect(resource().attributes).toMatchObject({
"service.namespace": "anomalyco",
@@ -32,26 +52,298 @@ describe("resource", () => {
})
test("drops OTEL resource attributes when any entry is invalid", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_SERVICE_NAMESPACE}=anomalyco,broken`
expect(resource().attributes["service.namespace"]).toBeUndefined()
expect(resource().attributes["opencode.client"]).toBeDefined()
expect(resource().attributes[ATTR_SERVICE_NAMESPACE]).toBeUndefined()
expect(resource().attributes[ATTR_OPENCODE_CLIENT]).toBeDefined()
})
test("keeps built-in attributes when env values conflict", () => {
process.env.OPENCODE_CLIENT = "cli"
process.env.OTEL_RESOURCE_ATTRIBUTES =
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_OPENCODE_CLIENT}=web,${ATTR_SERVICE_INSTANCE_ID}=override,${ATTR_SERVICE_NAMESPACE}=anomalyco`
expect(resource().attributes).toMatchObject({
"opencode.client": "cli",
"service.namespace": "anomalyco",
[ATTR_OPENCODE_CLIENT]: "cli",
[ATTR_SERVICE_NAMESPACE]: "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
expect(resource().attributes[ATTR_SERVICE_INSTANCE_ID]).not.toBe("override")
expect(resource().attributes[ATTR_OPENCODE_RUN]).toMatch(/^[0-9a-f]{8}$/)
})
test("uses deployment environment from OTEL resource attributes", () => {
process.env.OTEL_RESOURCE_ATTRIBUTES = `${ATTR_DEPLOYMENT_ENVIRONMENT_NAME}=development`
expect(resource().attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]).toBe("development")
})
})
it.effect("retains an execution trace parent until the execution settles", () =>
Effect.gen(function* () {
const telemetry = SessionTelemetry.makeExecution<string>()
const started = yield* Deferred.make<void>()
let parent: Span | undefined
yield* Effect.useSpan("parent", (span) =>
Effect.gen(function* () {
parent = span
const joiner = yield* telemetry
.resume("session", Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)))
.pipe(Effect.provideService(SessionTelemetry.TraceParent, span), Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(joiner)
}),
)
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(retained).toBe(parent)
yield* telemetry.settled("session")
const released = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(released).toBeUndefined()
}),
)
it.effect("retains an external ambient trace parent", () =>
Effect.gen(function* () {
const telemetry = SessionTelemetry.makeExecution<string>()
const parent = Tracer.externalSpan({ traceId: "1".repeat(32), spanId: "2".repeat(16) })
yield* telemetry.resume("session", Effect.void).pipe(Effect.withParentSpan(parent))
const retained = Option.getOrUndefined(yield* telemetry.drain("session", Effect.serviceOption(ParentSpan)))
expect(retained).toBe(parent)
}),
)
it.effect("detaches a top-level execution from its acquisition parent", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const telemetry = SessionTelemetry.makeExecution<string>()
yield* Effect.useSpan("startup", () =>
telemetry.drain(
"session",
AgentTelemetry.invoke({ sessionID: "session", agent: "build", errorType: () => "unknown" }, Effect.void),
),
).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(spans.find((span) => span.name === "invoke_agent build")?.parent._tag).toBe("None")
}),
)
it.effect("links a detached execution to its spawning span", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const telemetry = SessionTelemetry.makeExecution<string>()
yield* Effect.useSpan("execute_tool subagent", (parent) =>
telemetry
.resume("session", Effect.void)
.pipe(
Effect.provideService(SessionTelemetry.TraceParent, null),
Effect.provideService(SessionTelemetry.TraceLinks, [{ span: parent, attributes: {} }]),
),
).pipe(Effect.provideService(Tracer.Tracer, tracer))
yield* telemetry
.drain(
"session",
AgentTelemetry.invoke({ sessionID: "session", agent: "explore", errorType: () => "unknown" }, Effect.void),
)
.pipe(Effect.provideService(Tracer.Tracer, tracer))
const parent = spans.find((span) => span.name === "execute_tool subagent")
const child = spans.find((span) => span.name === "invoke_agent explore")
expect(child?.parent._tag).toBe("None")
expect(child?.links).toHaveLength(1)
expect(child?.links[0]?.span).toBe(parent)
}),
)
it.effect("links each agent turn to the previous Session turn", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const telemetry = SessionTelemetry.makeExecution<string>()
const run = telemetry
.drain(
"session",
AgentTelemetry.invoke({ sessionID: "session", agent: "build", errorType: () => "unknown" }, Effect.void),
)
.pipe(Effect.provideService(Tracer.Tracer, tracer))
yield* run
yield* telemetry.settled("session")
yield* run
const turns = spans.filter((span) => span.name === "invoke_agent build")
expect(turns).toHaveLength(2)
expect(turns[1]?.links).toHaveLength(1)
expect(turns[1]?.links[0]?.span).toBe(turns[0])
expect(turns[1]?.links[0]?.attributes[ATTR_OPENCODE_LINK_TYPE]).toBe("previous_turn")
}),
)
it.effect("keeps previous-turn links isolated by Session", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const telemetry = SessionTelemetry.makeExecution<string>()
const run = (sessionID: string) =>
telemetry
.drain(
sessionID,
AgentTelemetry.invoke({ sessionID, agent: "build", errorType: () => "unknown" }, Effect.void),
)
.pipe(Effect.provideService(Tracer.Tracer, tracer))
yield* run("a")
yield* run("b")
yield* run("a")
const a = spans.filter((span) => span.attributes.get(ATTR_GEN_AI_CONVERSATION_ID) === "a")
const b = spans.filter((span) => span.attributes.get(ATTR_GEN_AI_CONVERSATION_ID) === "b")
expect(a).toHaveLength(2)
expect(b).toHaveLength(1)
expect(a[1]?.links[0]?.span).toBe(a[0])
expect(b[0]?.links).toEqual([])
}),
)
it.effect("closes an active agent span when its execution scope is interrupted", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const started = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const fiber = yield* AgentTelemetry.invoke(
{ sessionID: "session", agent: "build", errorType: () => "unknown" },
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
).pipe(Effect.provideService(SessionTelemetry.TraceParent, null), Effect.forkChild)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "invoke_agent build")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
}),
)
it.effect("classifies a tool cause containing interruption as canceled", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const cause = Cause.fromReasons([
Cause.makeFailReason(new Error("concurrent failure")),
Cause.makeInterruptReason(),
])
const exit = yield* ToolTelemetry.execute(
{ sessionID: "session", agent: "explore", call: { id: "call", name: "read" } },
Effect.failCause(cause),
() => "tool.execution",
).pipe(Effect.exit, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "execute_tool read")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("cancellation")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("execution")
expect(span?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("canceled")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
expect(Exit.isFailure(exit) ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) : undefined).toBeInstanceOf(
Error,
)
}),
)
it.effect("applies HTTP response validation without a parent span", () =>
Effect.gen(function* () {
const request = HttpClientRequest.get("https://example.test/missing")
const http = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("missing", { status: 404 }))),
)
const exit = yield* HttpTelemetry.use(
http,
request,
Effect.succeed,
HttpClientResponse.filterStatusOk,
).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
it.effect("omits URL credentials, query, and fragment without changing the request", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const url = "https://user:password@example.test/path?region=us-east-1#fragment"
let executedUrl: string | undefined
const request = HttpClientRequest.get(url)
const http = HttpClient.make((request) => {
executedUrl = request.url
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok")))
})
yield* HttpTelemetry.use(http, request, Effect.succeed).pipe(
Effect.withSpan("execute_tool webfetch"),
Effect.provideService(Tracer.Tracer, tracer),
)
expect(executedUrl).toBe(url)
expect(spans.find((span) => span.name === "GET")?.attributes.get(ATTR_URL_FULL)).toBe(
"https://example.test/path",
)
}),
)
test("falls back to local logging when OTLP initialization fails", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-observability-test-"))
await using _ = {
+16 -2
View File
@@ -28,7 +28,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
import { Deferred, Effect, Exit, Fiber, Layer, Stream, Tracer } from "effect"
import { testEffect } from "./lib/effect"
import { location } from "./fixture/location"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -589,6 +590,14 @@ it.effect("waits for permission before calling an MCP tool", () =>
it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
@@ -604,12 +613,17 @@ it.effect("does not call MCP when permission is blocked", () =>
name: "execute",
input: { code: "return await tools.demo.search({})" },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" })
expect(settlement.output?.structured).toEqual({
toolCalls: [{ tool: "demo.search", status: "error" }],
error: true,
})
expect(calls).toBe(0)
const outer = spans.find((span) => span.name === "execute_tool execute")
const nested = spans.find((span) => span.name === "execute_tool demo_search")
expect(outer?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(nested?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(nested?.parent._tag === "Some" && nested.parent.value === outer).toBeTrue()
}),
)
@@ -1,4 +1,10 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import {
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_HTTP_REQUEST_METHOD,
} from "@opencode-ai/core/observability/semconv"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database"
@@ -35,10 +41,11 @@ import { Instructions } from "@opencode-ai/core/instructions"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Effect, Layer, References, Tracer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
@@ -92,22 +99,40 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[PluginSupervisor.node, pluginSupervisor],
])
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const telemetry = SessionTelemetry.makeExecution<SessionV2.ID>()
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
drain: (sessionID, force) => telemetry.drain(sessionID, sessionRunner.drain({ sessionID, force })),
settled: (sessionID) => telemetry.settled(sessionID),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.provideMerge(
Layer.mergeAll(
Layer.succeed(Tracer.Tracer, tracer),
Layer.succeed(References.TracerEnabled, false),
),
),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -149,6 +174,7 @@ const sessionID = SessionV2.ID.make("ses_runner_recorded")
describe("SessionRunnerLLM recorded", () => {
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
Effect.gen(function* () {
spans.length = 0
const agents = yield* AgentV2.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
@@ -182,7 +208,7 @@ describe("SessionRunnerLLM recorded", () => {
resume: false,
})
yield* session.resume(sessionID)
yield* session.resume(sessionID).pipe(Effect.provideService(SessionTelemetry.TraceParent, null))
const messages = yield* session.context(sessionID)
expect(messages).toHaveLength(2)
@@ -191,6 +217,16 @@ describe("SessionRunnerLLM recorded", () => {
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" },
])
const agent = spans.find((span) => span.name === "invoke_agent build")
const model = spans.find((span) => span.name === "chat gpt-4o-mini")
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(agent?.parent._tag).toBe("None")
expect(agent?.attributes.get(ATTR_GEN_AI_CONVERSATION_ID)).toBe(sessionID)
expect(model?.parent._tag === "Some" ? model.parent.value.spanId : undefined).toBe(agent?.spanId)
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(model?.spanId)
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBeNumber()
expect(model?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBeNumber()
expect(spans.filter((span) => span.name.startsWith("SessionRunner."))).toEqual([])
expect(
(yield* db
.select({ type: EventTable.type })
@@ -7,8 +7,22 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ATTR_ERROR_TYPE, ATTR_GEN_AI_TOOL_CALL_ID } from "@opencode-ai/core/observability/semconv"
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import {
Cause,
Deferred,
Effect,
Exit,
Fiber,
Layer,
Option,
Schema,
SchemaGetter,
SchemaIssue,
Scope,
Tracer,
} from "effect"
import { testEffect } from "./lib/effect"
const bounds: ToolOutputStore.BoundInput[] = []
@@ -215,6 +229,33 @@ describe("ToolRegistry", () => {
}),
)
it.effect("traces unknown and stale tool attempts", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize({ model: testModel })
yield* materialized.settle(call("missing", "call-unknown")).pipe(Effect.provideService(Tracer.Tracer, tracer))
yield* service.register({ echo: make() })
yield* materialized.settle(call("echo", "call-stale")).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(spans.map((span) => span.name)).toEqual(["execute_tool missing", "execute_tool echo"])
expect(spans[0]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.unknown")
expect(spans[0]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-unknown")
expect(spans[1]?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.stale")
expect(spans[1]?.attributes.get(ATTR_GEN_AI_TOOL_CALL_ID)).toBe("call-stale")
expect(spans.every((span) => span.status._tag === "Ended" && span.status.exit._tag === "Failure")).toBeTrue()
}),
)
it.effect("propagates retention failures through settlement", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
+336 -4
View File
@@ -8,9 +8,45 @@ import {
TransportReason,
InvalidRequestReason,
RateLimitReason,
Usage,
type LLMClientShape,
type LLMRequest,
} from "@opencode-ai/llm"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_AGENT_NAME,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
ATTR_GEN_AI_TOOL_CALL_ID,
ATTR_GEN_AI_TOOL_NAME,
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
ATTR_OPENCODE_AGENT_STEP_INDEX,
ATTR_OPENCODE_AGENT_STEP_TRIGGER,
ATTR_OPENCODE_COMPACTION_REASON,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_RETRY_DECISION,
ATTR_OPENCODE_RETRY_DELAY_SOURCE,
ATTR_OPENCODE_RETRY_MAX_ATTEMPTS,
ATTR_OPENCODE_RETRY_ATTEMPT,
ATTR_OPENCODE_RETRY_DELAY_MS,
ATTR_OPENCODE_SESSION_INPUT_COUNT,
ATTR_OPENCODE_SESSION_INPUT_DELIVERY,
ATTR_OPENCODE_SESSION_PARENT_ID,
ATTR_OPENCODE_TOOL_OUTCOME,
EVENT_OPENCODE_COMPACTION_FAILED,
EVENT_OPENCODE_COMPACTION_COMPLETED,
EVENT_OPENCODE_COMPACTION_STARTED,
EVENT_OPENCODE_RETRY_SCHEDULED,
EVENT_OPENCODE_RETRY_STOPPED,
EVENT_OPENCODE_SESSION_INPUT_PROMOTED,
} from "@opencode-ai/core/observability/semconv"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Database } from "@opencode-ai/core/database/database"
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
@@ -59,10 +95,11 @@ import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, References, Schema, Stream, Tracer } from "effect"
import { TestClock } from "effect/testing"
import { and, asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -345,22 +382,40 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[PluginSupervisor.node, pluginSupervisor],
])
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const telemetry = SessionTelemetry.makeExecution<SessionV2.ID>()
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
drain: (sessionID, force) => telemetry.drain(sessionID, sessionRunner.drain({ sessionID, force })),
settled: (sessionID) => telemetry.settled(sessionID),
})
return SessionExecution.Service.of({
active: coordinator.active,
resume: coordinator.run,
resume: (sessionID) => telemetry.resume(sessionID, coordinator.run(sessionID)),
wake: coordinator.wake,
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.provideMerge(
Layer.mergeAll(
Layer.succeed(Tracer.Tracer, tracer),
Layer.succeed(References.TracerEnabled, false),
),
),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -449,6 +504,7 @@ const setup = Effect.gen(function* () {
toolExecutionsReady = 5
activeToolExecutions = 0
maxActiveToolExecutions = 0
spans.length = 0
const agents = yield* AgentV2.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
@@ -752,6 +808,186 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("parents forked tool spans under the V2 agent turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, text: "Use echo", resume: false })
const usage = new Usage({
inputTokens: 8,
outputTokens: 3,
cacheReadInputTokens: 2,
cacheWriteInputTokens: 1,
reasoningTokens: 1,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-telemetry", name: "echo", input: { text: "hello" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls", usage }),
LLMEvent.finish({ reason: "tool-calls", usage }),
],
[],
]
yield* session.resume(sessionID)
const agent = spans.find((span) => span.name === "invoke_agent build")
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(agent?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
[ATTR_GEN_AI_AGENT_NAME, "build"],
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
]),
)
expect(agent?.events.find(([name]) => name === EVENT_OPENCODE_SESSION_INPUT_PROMOTED)?.[2]).toMatchObject({
[ATTR_OPENCODE_SESSION_INPUT_DELIVERY]: "steer",
[ATTR_OPENCODE_SESSION_INPUT_COUNT]: 1,
})
expect(agent?.parent._tag).toBe("None")
expect(spans.filter((span) => span.name.startsWith("SessionRunner."))).toEqual([])
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(8)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(3)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS)).toBe(2)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS)).toBe(1)
expect(agent?.attributes.get(ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS)).toBe(1)
expect(tool?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL],
[ATTR_GEN_AI_TOOL_NAME, "echo"],
[ATTR_GEN_AI_TOOL_CALL_ID, "call-telemetry"],
[ATTR_GEN_AI_CONVERSATION_ID, sessionID],
]),
)
expect(tool?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("completed")
expect(ancestorNames(tool).some((name) => name.startsWith("invoke_agent"))).toBeTrue()
expect(ancestorNames(tool)).not.toContain("SessionRunner.attemptStep")
expect(ancestorNames(tool)).not.toContain("chat fake-model")
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Success")
requests.length = 0
spans.length = 0
}),
)
it.effect("tracks V2 subagent sessions with their parent session", () =>
Effect.gen(function* () {
yield* setup
const agents = yield* AgentV2.Service
yield* agents.transform((editor) =>
editor.update(AgentV2.ID.make("explore"), (agent) => {
agent.mode = "subagent"
}),
)
const session = yield* SessionV2.Service
const child = yield* session.create({
id: otherSessionID,
parentID: sessionID,
agent: AgentV2.ID.make("explore"),
})
yield* session.prompt({
sessionID: child.id,
text: "Explore",
resume: false,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-subagent-telemetry", name: "echo", input: { text: "found" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(child.id)
const agent = spans.find((span) => span.name === "invoke_agent explore")
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(agent?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_OPERATION_NAME, GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT],
[ATTR_GEN_AI_AGENT_NAME, "explore"],
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
]),
)
expect(tool?.attributes).toMatchObject(
new Map([
[ATTR_GEN_AI_AGENT_NAME, "explore"],
[ATTR_GEN_AI_CONVERSATION_ID, child.id],
[ATTR_OPENCODE_SESSION_PARENT_ID, sessionID],
]),
)
expect(ancestorNames(tool).some((name) => name.startsWith("invoke_agent"))).toBeTrue()
}),
)
it.effect("distinguishes input and tool-result model calls", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
text: "Use echo twice",
resume: false,
})
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-context-1", name: "echo", input: { text: "first" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-context-2", name: "echo", input: { text: "second" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
const tools = spans.filter((span) => span.name === "execute_tool echo")
expect(tools).toHaveLength(2)
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(1)
expect(tools[0]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("input")
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_INDEX)).toBe(2)
expect(tools[1]?.attributes.get(ATTR_OPENCODE_AGENT_STEP_TRIGGER)).toBe("tool_result")
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
}),
)
it.effect("marks failed tool spans before returning the error to the model", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, text: "Fail tool", resume: false })
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-telemetry-failure", name: "echo", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
yield* session.resume(sessionID)
const tool = spans.find((span) => span.name === "execute_tool echo")
expect(tool?.attributes.get(ATTR_ERROR_TYPE)).toBe("tool.execution")
expect(tool?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("tool")
expect(tool?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("execution")
expect(tool?.attributes.get(ATTR_OPENCODE_TOOL_OUTCOME)).toBe("error")
expect(tool?.status._tag === "Ended" && tool.status.exit._tag).toBe("Failure")
requests.length = 0
spans.length = 0
}),
)
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -1491,6 +1727,10 @@ describe("SessionRunnerLLM", () => {
status: "completed",
summary: "durable summary",
})
const turn = spans.find((span) => span.name === "invoke_agent build")
expect(turn?.events.map(([name]) => name)).toEqual(
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_COMPLETED]),
)
}),
)
@@ -1531,6 +1771,10 @@ describe("SessionRunnerLLM", () => {
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
const turn = spans.find((span) => span.name === "invoke_agent build")
expect(turn?.events.map(([name]) => name)).toEqual(
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
)
}),
)
@@ -1554,6 +1798,10 @@ describe("SessionRunnerLLM", () => {
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
const turn = spans.find((span) => span.name === "invoke_agent build")
expect(turn?.events.map(([name]) => name)).toEqual(
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
)
}),
)
@@ -1577,6 +1825,10 @@ describe("SessionRunnerLLM", () => {
(type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1),
),
).toHaveLength(1)
const turn = spans.find((span) => span.name === "invoke_agent build")
expect(turn?.events.map(([name]) => name)).toEqual(
expect.arrayContaining([EVENT_OPENCODE_COMPACTION_STARTED, EVENT_OPENCODE_COMPACTION_FAILED]),
)
}),
)
@@ -1597,6 +1849,12 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(
spans
.filter((span) => span.name.startsWith("invoke_agent"))
.at(-1)
?.events.find(([name]) => name === EVENT_OPENCODE_COMPACTION_COMPLETED)?.[2],
).toMatchObject({ [ATTR_OPENCODE_COMPACTION_REASON]: "automatic" })
expect(userTexts(requests[0])[0]).toContain("## Objective")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
@@ -2266,6 +2524,7 @@ describe("SessionRunnerLLM", () => {
"user",
"assistant",
])
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
}),
)
@@ -2294,6 +2553,9 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(requests[0]!)).toEqual(["Start working"])
expect(userTexts(requests[1]!)).toEqual(["Start working"])
expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"])
const turns = spans.filter((span) => span.name === "invoke_agent build")
expect(turns).toHaveLength(2)
expect(turns[1]?.links.at(-1)?.span).toBe(turns[0])
}),
)
@@ -2387,6 +2649,11 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(requests[0]!)).toEqual(["Start working"])
expect(userTexts(requests[1]!)).toEqual(["Start working", "Queue first"])
expect(userTexts(requests[2]!)).toEqual(["Start working", "Queue first", "Queue second"])
const turns = spans.filter((span) => span.name === "invoke_agent build")
expect(turns).toHaveLength(3)
expect(new Set(turns.map((span) => span.traceId)).size).toBe(3)
expect(turns[1]?.links.at(-1)?.span).toBe(turns[0])
expect(turns[2]?.links.at(-1)?.span).toBe(turns[1])
}),
)
@@ -3289,6 +3556,9 @@ describe("SessionRunnerLLM", () => {
{ type: "assistant", finish: "error", error: { type: "aborted", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
const agent = spans.find((span) => span.name.startsWith("invoke_agent"))
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
expect(agent?.status._tag === "Ended" && agent.status.exit._tag).toBe("Failure")
yield* session.interrupt(sessionID)
}),
)
@@ -3565,13 +3835,29 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.retry.scheduled.1")
expect(
spans
.find((span) => span.name.startsWith("invoke_agent"))
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
).toMatchObject({
[ATTR_OPENCODE_RETRY_ATTEMPT]: 2,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
[ATTR_OPENCODE_RETRY_DELAY_MS]: 2_000,
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "backoff",
[ATTR_OPENCODE_RETRY_DECISION]: "scheduled",
[ATTR_ERROR_TYPE]: "provider.transport",
})
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(
spans.find((span) => span.name.startsWith("invoke_agent"))?.attributes.has(ATTR_OPENCODE_ERROR_STAGE),
).toBeFalse()
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
yield* replaySessionProjection(sessionID)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
expect(spans.filter((span) => span.name === "invoke_agent build")).toHaveLength(1)
}),
)
@@ -3589,6 +3875,14 @@ describe("SessionRunnerLLM", () => {
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(
spans
.find((span) => span.name === "invoke_agent build")
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_SCHEDULED)?.[2],
).toMatchObject({
[ATTR_OPENCODE_RETRY_DELAY_MS]: 5_000,
[ATTR_OPENCODE_RETRY_DELAY_SOURCE]: "max(backoff,retry_after)",
})
}),
)
@@ -3621,8 +3915,26 @@ describe("SessionRunnerLLM", () => {
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
expect(
spans
.find((span) => span.name.startsWith("invoke_agent"))
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
).toMatchObject({
[ATTR_OPENCODE_RETRY_DECISION]: "exhausted",
[ATTR_OPENCODE_RETRY_ATTEMPT]: 5,
[ATTR_OPENCODE_RETRY_MAX_ATTEMPTS]: 5,
})
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
const agent = spans.find((span) => span.name.startsWith("invoke_agent"))
expect(agent?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider.transport")
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
expect(agent?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("model")
if (agent?.status._tag === "Ended" && agent.status.exit._tag === "Failure") {
const failure = Cause.squash(agent.status.exit.cause)
expect(failure).toMatchObject({ message: "provider.transport" })
expect(failure).not.toMatchObject({ message: "Provider unavailable" })
}
}),
)
@@ -3650,6 +3962,11 @@ describe("SessionRunnerLLM", () => {
expect(eventTypes.filter((type) => type === "session.step.started.1")).toHaveLength(2)
expect(eventTypes.filter((type) => type === "session.retry.scheduled.1")).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "assistant")).toHaveLength(1)
expect(
spans
.find((span) => span.name === "invoke_agent build")
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
).toMatchObject({ [ATTR_OPENCODE_RETRY_DECISION]: "step_limit" })
}),
)
@@ -3663,6 +3980,11 @@ describe("SessionRunnerLLM", () => {
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
expect(requests).toHaveLength(1)
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
expect(
spans
.find((span) => span.name === "invoke_agent build")
?.events.find(([name]) => name === EVENT_OPENCODE_RETRY_STOPPED)?.[2],
).toMatchObject({ [ATTR_OPENCODE_RETRY_DECISION]: "non_retryable" })
}),
)
@@ -4003,3 +4325,13 @@ describe("SessionRunnerLLM", () => {
}),
)
})
function ancestorNames(span: Tracer.NativeSpan | undefined) {
const names: string[] = []
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
while (current?._tag === "Span") {
names.push(current.name)
current = current.parent._tag === "Some" ? current.parent.value : undefined
}
return names
}
+55 -4
View File
@@ -1,5 +1,10 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream, Tracer } from "effect"
import {
ATTR_OPENCODE_LINK_TYPE,
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
} from "@opencode-ai/core/observability/semconv"
import { Money } from "@opencode-ai/schema/money"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -25,6 +30,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
@@ -33,6 +39,11 @@ const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const resumedContexts: Array<{
readonly sessionID: SessionV2.ID
readonly parent: Tracer.AnySpan | null | undefined
readonly links: ReadonlyArray<Tracer.SpanLink>
}> = []
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
@@ -79,7 +90,15 @@ const executionNode = makeGlobalNode({
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: complete,
resume: (sessionID) =>
Effect.gen(function* () {
resumedContexts.push({
sessionID,
parent: yield* SessionTelemetry.TraceParent,
links: yield* SessionTelemetry.TraceLinks,
})
return yield* complete(sessionID)
}),
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
@@ -146,6 +165,7 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
resumedContexts.length = 0
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
SubagentTool.name,
)
@@ -180,6 +200,14 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const settled = yield* settleTool(registry, {
sessionID: parent.id,
@@ -190,7 +218,7 @@ describe("SubagentTool", () => {
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
@@ -200,6 +228,12 @@ describe("SubagentTool", () => {
agent: "reviewer",
model: childModel,
})
const span = spans.find((span) => span.name === "execute_tool subagent")
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_AGENT_NAME)).toBe("reviewer")
expect(span?.attributes.get(ATTR_OPENCODE_SUBAGENT_SESSION_ID)).toBe(child.id)
const resumed = resumedContexts.find((context) => context.sessionID === child.id)
expect(resumed?.parent?.spanId).toBe(span?.spanId)
expect(resumed?.links).toEqual([])
const fallback = yield* settleTool(registry, {
sessionID: parent.id,
@@ -264,6 +298,15 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
resumedContexts.length = 0
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const events = yield* EventV2.Service
const admitted = yield* events.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
@@ -281,7 +324,7 @@ describe("SubagentTool", () => {
name: SubagentTool.name,
input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
},
})
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
@@ -293,6 +336,14 @@ describe("SubagentTool", () => {
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText)
const resumed = resumedContexts.find((context) => context.sessionID === childID)
expect(resumed?.parent).toBeNull()
const span = spans.find((span) => span.name === "execute_tool subagent")
expect(span).toBeDefined()
if (!span) return
expect(resumed?.links).toHaveLength(1)
expect(resumed?.links[0]?.span.spanId).toBe(span.spanId)
expect(resumed?.links[0]?.attributes[ATTR_OPENCODE_LINK_TYPE]).toBe("subagent")
}),
),
),
+53 -2
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test"
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
import { Duration, Effect, Fiber, Layer, Schema, Tracer } from "effect"
import {
ATTR_ERROR_TYPE,
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
} from "@opencode-ai/core/observability/semconv"
import * as TestClock from "effect/testing/TestClock"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -88,9 +95,21 @@ describe("WebFetchTool registration", () => {
reset()
const registry = yield* ToolRegistry.Service
const url = "http://example.com/public"
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"])
expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({
expect(
yield* settleTool(registry, call({ url, format: "text", timeout: 4 })).pipe(
Effect.provideService(Tracer.Tracer, tracer),
),
).toEqual({
result: { type: "text", value: "hello" },
output: {
structured: { url, contentType: "text/plain", format: "text", output: "hello" },
@@ -101,6 +120,38 @@ describe("WebFetchTool registration", () => {
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
])
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
const tool = spans.find((span) => span.name === "execute_tool webfetch")
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
expect(http?.name).toBe("GET")
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(80)
expect(http?.attributes.get(ATTR_URL_FULL)).toBe(url)
expect(http?.parent._tag === "Some" ? http.parent.value.spanId : undefined).toBe(tool?.spanId)
expect(requests[0]?.headers.traceparent).toBeUndefined()
}),
)
it.effect("marks rejected HTTP statuses on the HTTP span", () =>
Effect.gen(function* () {
reset()
respond = () => Effect.succeed(new Response("missing", { status: 404 }))
const registry = yield* ToolRegistry.Service
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
yield* settleTool(registry, call({ url: "https://example.com/missing", format: "text" })).pipe(
Effect.provideService(Tracer.Tracer, tracer),
)
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "GET")
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(404)
expect(http?.attributes.get(ATTR_ERROR_TYPE)).toBe("StatusCodeError")
expect(http?.status._tag === "Ended" && http.status.exit._tag).toBe("Failure")
}),
)
+1
View File
@@ -178,6 +178,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
// Protocol ids are open strings, so external packages can define their own
// protocols without changing this package.
id: "fake-echo",
operation: "chat",
body: {
schema: FakeBody,
from: (request) =>
+1
View File
@@ -14,6 +14,7 @@
".": "./src/index.ts",
"./route": "./src/route/index.ts",
"./provider": "./src/provider.ts",
"./semconv": "./src/semconv.ts",
"./providers": "./src/providers/index.ts",
"./provider-package": "./src/provider-package.ts",
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
@@ -20,7 +20,7 @@ import {
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { isContextOverflow } from "../provider-error"
import * as Cache from "./utils/cache"
import { newBreakpoints, ttlBucket, type Breakpoints } from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream"
@@ -254,14 +254,14 @@ const ANTHROPIC_BREAKPOINT_CAP = 4
const EPHEMERAL_5M = { type: "ephemeral" as const }
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
const cacheControl = (breakpoints: Breakpoints, cache: CacheHint | undefined) => {
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
if (breakpoints.remaining <= 0) {
breakpoints.dropped += 1
return undefined
}
breakpoints.remaining -= 1
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
return ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
@@ -272,7 +272,7 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
const lowerTool = (breakpoints: Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: inputSchema,
@@ -400,7 +400,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
message: LLMRequest["messages"][number],
breakpoints: Cache.Breakpoints,
breakpoints: Breakpoints,
) {
const content = yield* ProviderShared.systemUpdateText("Anthropic Messages", message)
return {
@@ -415,7 +415,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
request: LLMRequest,
breakpoints: Cache.Breakpoints,
breakpoints: Breakpoints,
) {
const messages: AnthropicMessage[] = []
@@ -541,7 +541,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const breakpoints = newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined
@@ -862,6 +862,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: AnthropicMessagesBody,
from: fromRequest,
@@ -636,6 +636,7 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: BedrockConverseBody,
from: fromRequest,
+1
View File
@@ -485,6 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "generate_content",
body: {
schema: GeminiBody,
from: fromRequest,
@@ -476,6 +476,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: OpenAIChatBody,
from: fromRequest,
@@ -964,6 +964,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: OpenAIResponsesBody,
from: fromRequest,
+1
View File
@@ -37,6 +37,7 @@ export type OpenRouterBody = Schema.Schema.Type<typeof OpenRouterBody>
export const protocol = Protocol.make({
id: "openrouter-chat",
operation: OpenAIChat.protocol.operation,
body: {
schema: OpenRouterBody,
from: (request) =>
+45 -32
View File
@@ -1,16 +1,16 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import * as Option from "effect/Option"
import { Auth, type Auth as AuthDef } from "./auth"
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Auth } from "./auth"
import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import type { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import type { GenAIOperation, Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import { LLMTelemetry } from "../telemetry"
import { ProviderShared } from "../protocols/shared"
import type { LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import {
GenerationOptions,
HttpOptions,
@@ -18,7 +18,7 @@ import {
LLMResponse,
Model,
ModelLimits,
LLMError as LLMErrorClass,
LLMError,
PreparedRequest,
ProviderID,
mergeGenerationOptions,
@@ -39,8 +39,9 @@ export interface Route<Body, Prepared = unknown> {
/** ProviderMetadata namespace emitted and consumed by this route. */
readonly providerMetadataKey?: string
readonly protocol: ProtocolID
readonly operation: GenAIOperation
readonly endpoint: Endpoint<Body>
readonly auth: AuthDef
readonly auth: Auth
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
@@ -85,7 +86,7 @@ export interface RouteDefaultsInput {
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly id?: string
readonly provider?: string | ProviderID
readonly auth?: AuthDef
readonly auth?: Auth
readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
}
@@ -193,7 +194,7 @@ export interface MakeInput<Body, Frame, Event, State> {
/** Where the request is sent. */
readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
readonly auth?: AuthDef
readonly auth?: Auth
/** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */
readonly framing: Framing<Frame>
/** Static / per-request headers added before `auth` runs. */
@@ -214,7 +215,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
/** Where the request is sent. */
readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
readonly auth?: AuthDef
readonly auth?: Auth
/** Static / per-request headers added before `auth` runs. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
/** Runnable transport route. */
@@ -225,7 +226,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof LLMErrorClass) return failed
if (failed instanceof LLMError) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
@@ -256,6 +257,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
operation: protocol.operation,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
transport: routeInput.transport,
@@ -349,22 +351,24 @@ export function make<Body, Prepared, Frame, Event, State>(
// `compile` is the important boundary: it turns a common `LLMRequest` into a
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = resolved.model.route
const compileResolved = (resolved: LLMRequest) =>
Effect.gen(function* () {
const route = resolved.model.route
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)
const body = yield* route.body
.from(resolved)
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)
return {
request: resolved,
route,
body,
prepared,
}
})
return {
request: resolved,
route,
body,
prepared,
}
})
const compile = (request: LLMRequest) => compileResolved(applyCachePolicy(resolveRequestOptions(request)))
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
const compiled = yield* compile(request)
@@ -379,13 +383,22 @@ const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMReques
})
})
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
}),
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => {
return Stream.unwrap(
Effect.sync(() => applyCachePolicy(resolveRequestOptions(request))).pipe(
Effect.map((resolved) =>
LLMTelemetry.stream(
resolved,
Stream.unwrap(
compileResolved(resolved).pipe(
Effect.map((compiled) => compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)),
),
),
),
),
),
)
}
const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
+4
View File
@@ -1,6 +1,8 @@
import { Schema, type Effect } from "effect"
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
export type GenAIOperation = "chat" | "generate_content"
/**
* The semantic API contract of one model server family.
*
@@ -36,6 +38,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
export interface Protocol<Body, Frame, Event, State> {
/** Stable id for the wire protocol implementation. */
readonly id: ProtocolID
/** OpenTelemetry GenAI operation represented by one request. */
readonly operation: GenAIOperation
/** Request side: schema for the provider-native body and how to build it. */
readonly body: ProtocolBody<Body>
/** Response side: streaming state machine. */
+31 -20
View File
@@ -1,11 +1,12 @@
import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing, type Framing as FramingDef } from "../framing"
import { Endpoint } from "../endpoint"
import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { ProviderShared } from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
import { LLMHttpTelemetry } from "../../telemetry/http"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -18,7 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly framing: FramingDef<Frame>
readonly framing: Framing<Frame>
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -88,7 +89,7 @@ const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (bod
export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
Effect.gen(function* () {
const url = applyQuery(
renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(),
Endpoint.render(input.endpoint, { request: input.request, body: input.body }).toString(),
input.request.http?.query,
)
const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody)
@@ -106,7 +107,7 @@ export const jsonRequestParts = <Body>(input: JsonRequestInput<Body>) =>
})
export interface HttpJsonInput<_Body, Frame> {
readonly framing: FramingDef<Frame>
readonly framing: Framing<Frame>
}
export type HttpJsonPatch<Body, Frame> = Partial<HttpJsonInput<Body, Frame>>
@@ -128,20 +129,30 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
})),
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
LLMHttpTelemetry.stream(
prepared.request,
(providerRequest) =>
Stream.unwrap(
runtime.http.execute(providerRequest).pipe(
Effect.map((response) =>
Stream.unwrap(
Effect.gen(function* () {
const received = yield* LLMHttpTelemetry.ResponseReceived
if (received) yield* received(response.status)
const firstChunk = yield* LLMHttpTelemetry.ResponseChunkReceived
return prepared.framing.frame(
response.stream.pipe(
Stream.tap(() => firstChunk ?? Effect.void),
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
)
}),
),
),
),
+23 -14
View File
@@ -1,7 +1,8 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Cause, Clock, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema"
import * as HttpTransport from "./http"
import { LLMWebSocketTelemetry } from "../../telemetry/websocket"
import { jsonRequestParts } from "./http"
import type { Transport } from "./index"
export interface WebSocketRequest {
@@ -228,7 +229,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
with: (patch) => json({ ...input, ...patch }),
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts({
const parts = yield* jsonRequestParts({
...prepareInput,
})
return {
@@ -239,24 +240,32 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
}),
frames: (prepared, _request, runtime) => {
const webSocket = runtime.webSocket
if (!webSocket) {
if (!webSocket)
return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url,
kind: "websocket",
}),
)
}
const decoder = new TextDecoder()
return Stream.unwrap(
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
webSocket.open({ url: prepared.url, headers: prepared.headers }),
(connection) => connection.close,
)
yield* connection.sendText(prepared.message)
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
}),
return LLMWebSocketTelemetry.stream(
prepared.url,
Stream.unwrap(
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
webSocket.open({ url: prepared.url, headers: prepared.headers }),
(connection) => connection.close,
)
const requestIssued = yield* LLMWebSocketTelemetry.RequestIssued
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
yield* connection.sendText(prepared.message)
const firstChunk = yield* LLMWebSocketTelemetry.ResponseChunkReceived
return connection.messages.pipe(
Stream.tap(() => firstChunk ?? Effect.void),
Stream.map((message) => messageText(message, decoder)),
)
}),
),
)
},
})
+55
View File
@@ -0,0 +1,55 @@
export * as LLMSemconv from "./semconv"
// Keep this projection synced with the OpenTelemetry semantic conventions.
// GenAI conventions are specification-only and do not publish a JavaScript package:
// https://github.com/open-telemetry/semantic-conventions-genai/blob/c321d7eb4443ae1d1d88c2e24eda849f62049008/model/gen-ai/registry.yaml
export const ATTR_ERROR_TYPE = "error.type"
export const ATTR_HTTP_REQUEST_METHOD = "http.request.method"
export const ATTR_HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
export const ATTR_NETWORK_PROTOCOL_NAME = "network.protocol.name"
export const ATTR_NETWORK_TRANSPORT = "network.transport"
export const ATTR_SERVER_ADDRESS = "server.address"
export const ATTR_SERVER_PORT = "server.port"
export const ATTR_URL_FULL = "url.full"
export const ATTR_URL_PATH = "url.path"
export const ATTR_URL_SCHEME = "url.scheme"
export const ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
export const ATTR_GEN_AI_OUTPUT_TYPE = "gen_ai.output.type"
export const ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
export const ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
export const ATTR_GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
export const ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
export const ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
export const ATTR_GEN_AI_REQUEST_SEED = "gen_ai.request.seed"
export const ATTR_GEN_AI_REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences"
export const ATTR_GEN_AI_REQUEST_STREAM = "gen_ai.request.stream"
export const ATTR_GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
export const ATTR_GEN_AI_REQUEST_TOP_K = "gen_ai.request.top_k"
export const ATTR_GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"
export const ATTR_GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
export const ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk"
export const ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
export const ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
export const ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
export const ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
export const ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS = "gen_ai.usage.reasoning.output_tokens"
export const ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
export const ATTR_GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
export const ATTR_GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
export const ATTR_GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
// OpenCode extensions used alongside the upstream GenAI conventions.
export const ATTR_OPENCODE_ERROR_SOURCE = "opencode.error.source"
export const ATTR_OPENCODE_ERROR_STAGE = "opencode.error.stage"
export const ATTR_OPENCODE_LLM_PROTOCOL = "opencode.llm.protocol"
export const ATTR_OPENCODE_LLM_ROUTE = "opencode.llm.route"
export const ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE = "opencode.provider.http.status_code"
export const ATTR_OPENCODE_PROVIDER_REQUEST_ID = "opencode.provider.request.id"
export const ATTR_OPENCODE_TRANSPORT_KIND = "opencode.transport.kind"
export const GEN_AI_OPERATION_NAME_VALUE_CHAT = "chat"
export const GEN_AI_OPERATION_NAME_VALUE_GENERATE_CONTENT = "generate_content"
export const GEN_AI_OUTPUT_TYPE_VALUE_JSON = "json"
export const GEN_AI_OUTPUT_TYPE_VALUE_TEXT = "text"
+306
View File
@@ -0,0 +1,306 @@
export * as LLMTelemetry from "./telemetry"
import { Cause, Clock, Effect, Exit, References, Stream } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_OUTPUT_TYPE,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY,
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY,
ATTR_GEN_AI_REQUEST_SEED,
ATTR_GEN_AI_REQUEST_STOP_SEQUENCES,
ATTR_GEN_AI_REQUEST_STREAM,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_K,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
ATTR_OPENCODE_LLM_PROTOCOL,
ATTR_OPENCODE_LLM_ROUTE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE,
ATTR_OPENCODE_PROVIDER_REQUEST_ID,
ATTR_OPENCODE_TRANSPORT_KIND,
ATTR_SERVER_ADDRESS,
ATTR_SERVER_PORT,
GEN_AI_OUTPUT_TYPE_VALUE_JSON,
GEN_AI_OUTPUT_TYPE_VALUE_TEXT,
} from "./semconv"
import { LLMError, LLMEvent, type LLMRequest, type Usage } from "./schema"
import { RequestIssued, ResponseChunkReceived } from "./telemetry/http"
export { RequestIssued, ResponseChunkReceived } from "./telemetry/http"
type TelemetryState = {
requestIssued?: bigint
firstChunkReceived?: bigint
usage?: Usage
terminal?: Exit.Exit<void, Error>
}
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
export function stream(request: LLMRequest, source: Stream.Stream<LLMEvent, LLMError>) {
const generation = request.generation
const operation = request.model.route.operation
const baseURL = request.model.route.endpoint.baseURL
const url = baseURL && URL.canParse(baseURL) ? new URL(baseURL) : undefined
const server = serverAttributes(url)
const outputType =
request.responseFormat?.type === "text"
? GEN_AI_OUTPUT_TYPE_VALUE_TEXT
: request.responseFormat?.type === "json"
? GEN_AI_OUTPUT_TYPE_VALUE_JSON
: undefined
const attributes = {
[ATTR_GEN_AI_OPERATION_NAME]: operation,
[ATTR_GEN_AI_PROVIDER_NAME]: request.model.provider,
[ATTR_GEN_AI_REQUEST_MODEL]: request.model.id,
[ATTR_GEN_AI_REQUEST_STREAM]: true,
[ATTR_OPENCODE_LLM_ROUTE]: request.model.route.id,
[ATTR_OPENCODE_LLM_PROTOCOL]: request.model.route.protocol,
...server,
...(generation?.maxTokens === undefined ? {} : { [ATTR_GEN_AI_REQUEST_MAX_TOKENS]: generation.maxTokens }),
...(generation?.temperature === undefined ? {} : { [ATTR_GEN_AI_REQUEST_TEMPERATURE]: generation.temperature }),
...(generation?.topK === undefined || !Number.isInteger(generation.topK)
? {}
: { [ATTR_GEN_AI_REQUEST_TOP_K]: generation.topK }),
...(generation?.topP === undefined ? {} : { [ATTR_GEN_AI_REQUEST_TOP_P]: generation.topP }),
...(generation?.frequencyPenalty === undefined
? {}
: { [ATTR_GEN_AI_REQUEST_FREQUENCY_PENALTY]: generation.frequencyPenalty }),
...(generation?.presencePenalty === undefined
? {}
: { [ATTR_GEN_AI_REQUEST_PRESENCE_PENALTY]: generation.presencePenalty }),
...(generation?.seed === undefined ? {} : { [ATTR_GEN_AI_REQUEST_SEED]: generation.seed }),
...(generation?.stop === undefined ? {} : { [ATTR_GEN_AI_REQUEST_STOP_SEQUENCES]: generation.stop }),
...(outputType === undefined ? {} : { [ATTR_GEN_AI_OUTPUT_TYPE]: outputType }),
}
return Stream.unwrap(
Effect.gen(function* () {
if (!(yield* References.TracerEnabled)) return source
const span = yield* Effect.makeSpan(`${operation} ${request.model.id}`, {
kind: "client",
attributes,
})
const state: TelemetryState = {}
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
return observeStream({
span,
state,
stream: source,
stopSequences: generation?.stop,
})
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.succeed(source),
),
),
)
}
function observeStream(input: {
readonly span: Span
readonly state: TelemetryState
readonly stream: Stream.Stream<LLMEvent, LLMError>
readonly stopSequences?: ReadonlyArray<string>
}) {
const terminate = (exit: Exit.Exit<void, Error>, errorType?: string) => {
if (input.state.terminal) return false
input.state.terminal = exit
if (errorType) input.span.attribute(ATTR_ERROR_TYPE, errorType)
return true
}
return input.stream.pipe(
Stream.onStart(
observe(
Effect.sync(() => {
if (input.stopSequences !== undefined)
input.span.attribute(ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, [...input.stopSequences])
}),
),
),
Stream.tap((event) =>
observe(
Effect.sync(() => {
if (input.state.terminal) return
if ("usage" in event && event.usage !== undefined) input.state.usage = event.usage
if (LLMEvent.is.finish(event)) {
const type = event.reason === "error" ? "provider_error" : undefined
if (!terminate(type ? Exit.fail(new Error(type)) : Exit.void, type)) return
const attributes = finishAttributes(event)
for (const [key, value] of Object.entries(attributes)) input.span.attribute(key, value)
if (type) {
input.span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "provider")
input.span.attribute(ATTR_OPENCODE_ERROR_STAGE, "response")
}
}
if (LLMEvent.is.providerError(event)) {
const type = errorType(event)
input.span.attribute(ATTR_OPENCODE_ERROR_SOURCE, "provider")
input.span.attribute(ATTR_OPENCODE_ERROR_STAGE, "response")
terminate(Exit.fail(new Error(type)), type)
}
}),
),
),
Stream.tapCause((cause) =>
observe(
Effect.gen(function* () {
const type = Cause.hasInterruptsOnly(cause) ? "canceled" : causeErrorType(cause)
const error = Cause.squash(cause)
if (error instanceof LLMError) {
for (const [key, value] of Object.entries(llmErrorAttributes(error))) input.span.attribute(key, value)
}
terminate(Exit.fail(new Error(type)), type)
}),
),
),
Stream.provideService(ParentSpan, input.span),
Stream.provideService(RequestIssued, (time) =>
observe(
Effect.sync(() => {
input.state.requestIssued ??= time
recordFirstChunk(input.span, input.state)
}),
),
),
Stream.provideService(
ResponseChunkReceived,
observe(
Effect.gen(function* () {
input.state.firstChunkReceived ??= yield* Clock.currentTimeNanos
recordFirstChunk(input.span, input.state)
}),
),
),
Stream.provideService(References.TracerEnabled, false),
)
}
function finalize(span: Span, state: TelemetryState, scopeExit: Exit.Exit<unknown, unknown>) {
return Effect.gen(function* () {
if (!state.terminal) {
const type = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
? "canceled"
: "incomplete_response"
span.attribute(ATTR_ERROR_TYPE, type)
state.terminal = Exit.fail(new Error(type))
}
if (state.usage) {
for (const [key, value] of Object.entries(usageAttributes(state.usage))) span.attribute(key, value)
}
span.end(yield* Clock.currentTimeNanos, state.terminal)
})
}
function recordFirstChunk(span: Span, state: TelemetryState) {
if (state.requestIssued === undefined || state.firstChunkReceived === undefined) return
span.attribute(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK, Number(state.firstChunkReceived - state.requestIssued) / 1e9)
}
function finishAttributes(event: Extract<LLMEvent, { readonly type: "finish" }>) {
return {
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: [finishReason(event.reason)],
}
}
function finishReason(reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"]) {
if (reason === "tool-calls") return "tool_calls"
if (reason === "content-filter") return "content_filter"
return reason
}
function errorType(event: Extract<LLMEvent, { readonly type: "provider-error" }>) {
if (event.classification) return event.classification
return "provider_error"
}
function llmErrorAttributes(error: LLMError) {
const reason = error.reason
const http = "http" in reason ? reason.http : undefined
const status = http?.response?.status
const providerStream = error.method === "stream"
const source =
reason._tag === "Transport"
? "transport"
: reason._tag === "InvalidProviderOutput"
? "protocol"
: reason._tag === "NoRoute"
? "configuration"
: providerStream
? "provider"
: reason._tag === "InvalidRequest" && http === undefined
? "request"
: reason._tag === "Authentication" && http === undefined
? "configuration"
: "provider"
const stage =
reason._tag === "Transport"
? "transport"
: reason._tag === "InvalidProviderOutput"
? "parse"
: reason._tag === "NoRoute"
? "resolve"
: providerStream
? "response"
: http === undefined && (reason._tag === "InvalidRequest" || reason._tag === "Authentication")
? "compile"
: "response"
return {
[ATTR_OPENCODE_ERROR_SOURCE]: source,
[ATTR_OPENCODE_ERROR_STAGE]: stage,
...(status === undefined ? {} : { [ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE]: status }),
...(http?.requestId === undefined ? {} : { [ATTR_OPENCODE_PROVIDER_REQUEST_ID]: http.requestId }),
...(reason._tag === "Transport" && reason.kind ? { [ATTR_OPENCODE_TRANSPORT_KIND]: reason.kind } : {}),
}
}
function causeErrorType(cause: Cause.Cause<unknown>) {
const error = Cause.squash(cause)
if (error instanceof LLMError) return error.reason._tag
if (error instanceof Error) return error.name
return "unknown"
}
function usageAttributes(usage: Usage | undefined) {
if (!usage) return {}
return {
...(usage.inputTokens === undefined ? {} : { [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: usage.inputTokens }),
...(usage.outputTokens === undefined ? {} : { [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: usage.outputTokens }),
...(usage.cacheReadInputTokens === undefined
? {}
: { [ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: usage.cacheReadInputTokens }),
...(usage.cacheWriteInputTokens === undefined
? {}
: { [ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: usage.cacheWriteInputTokens }),
...(usage.reasoningTokens === undefined
? {}
: { [ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: usage.reasoningTokens }),
}
}
function serverAttributes(url: URL | undefined) {
if (!url) return {}
const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : url.protocol === "http:" ? 80 : undefined
return {
[ATTR_SERVER_ADDRESS]: url.hostname,
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Effect, Option } from "effect"
import type { Span } from "effect/Tracer"
import { ATTR_OPENCODE_LLM_ROUTE } from "../semconv"
export const currentModelSpan = Effect.option(Effect.currentSpan).pipe(
Effect.map(Option.getOrUndefined),
Effect.map(findModelSpan),
)
function findModelSpan(span: Span | undefined): Span | undefined {
if (!span) return
if (span.attributes.has(ATTR_OPENCODE_LLM_ROUTE)) return span
const parent = Option.getOrUndefined(span.parent)
return findModelSpan(parent?._tag === "Span" ? parent : undefined)
}
+171
View File
@@ -0,0 +1,171 @@
export * as LLMHttpTelemetry from "./http"
import { Cause, Clock, Context, Effect, Exit, Option, References, Stream } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import { HttpClient, HttpClientRequest, HttpTraceContext } from "effect/unstable/http"
import {
ATTR_ERROR_TYPE,
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_SERVER_ADDRESS,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
ATTR_URL_PATH,
ATTR_URL_SCHEME,
} from "../semconv"
import { LLMError } from "../schema"
import { currentModelSpan } from "./context"
export const RequestIssued = Context.Reference<((time: bigint) => Effect.Effect<void>) | undefined>(
"@opencode/LLM/Telemetry/RequestIssued",
{ defaultValue: () => undefined },
)
export const ResponseReceived = Context.Reference<((status: number) => Effect.Effect<void>) | undefined>(
"@opencode/LLM/Telemetry/ResponseReceived",
{ defaultValue: () => undefined },
)
export const ResponseChunkReceived = Context.Reference<Effect.Effect<void> | undefined>(
"@opencode/LLM/Telemetry/ResponseChunkReceived",
{ defaultValue: () => undefined },
)
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
export const stream = <A, R>(
request: HttpClientRequest.HttpClientRequest,
source: (request: HttpClientRequest.HttpClientRequest) => Stream.Stream<A, LLMError, R>,
) =>
Stream.unwrap(
Effect.gen(function* () {
const parent = yield* currentModelSpan
if (!parent) return source(request)
const url = URL.canParse(request.url) ? new URL(request.url) : undefined
const port = url?.port
? Number(url.port)
: url?.protocol === "https:"
? 443
: url?.protocol === "http:"
? 80
: undefined
const span = yield* Effect.makeSpan(request.method, {
kind: "client",
parent,
attributes: {
[ATTR_HTTP_REQUEST_METHOD]: request.method,
...(url
? {
[ATTR_SERVER_ADDRESS]: url.hostname,
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
[ATTR_URL_FULL]: safeUrl(url),
[ATTR_URL_PATH]: url.pathname,
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
}
: {}),
},
})
const state: State = { responseReceived: false }
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
return observeStream(
span,
state,
source(HttpClientRequest.setHeaders(request, HttpTraceContext.toHeaders(span))),
)
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.succeed(source(request)),
),
),
)
type State = {
responseReceived: boolean
terminal?: Exit.Exit<void, unknown>
}
function observeStream<A, R>(span: Span, state: State, source: Stream.Stream<A, LLMError, R>) {
const terminate = (exit: Exit.Exit<void, unknown>, type?: string) => {
if (state.terminal) return
state.terminal = exit
if (type) span.attribute(ATTR_ERROR_TYPE, type)
}
return source.pipe(
Stream.onStart(
observe(
Effect.gen(function* () {
const requestIssued = yield* RequestIssued
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
}),
),
),
Stream.tapCause((cause) =>
observe(
Effect.gen(function* () {
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
if (Cause.hasInterruptsOnly(cause)) {
terminate(Exit.failCause(cause))
return
}
const type = error?.reason._tag ?? "unknown"
const status = error && "http" in error.reason ? error.reason.http?.response?.status : undefined
if (status !== undefined) span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
span.attribute(
ATTR_OPENCODE_ERROR_SOURCE,
error?.reason._tag === "Transport"
? "transport"
: error?.reason._tag === "InvalidProviderOutput"
? "protocol"
: "provider",
)
span.attribute(
ATTR_OPENCODE_ERROR_STAGE,
status !== undefined ? "response" : state.responseReceived ? "response_stream" : "request",
)
terminate(Exit.fail(new Error(type)), type)
}),
),
),
Stream.provideService(ParentSpan, span),
Stream.provideService(ResponseReceived, (status) =>
observe(
Effect.sync(() => {
state.responseReceived = true
span.attribute(ATTR_HTTP_RESPONSE_STATUS_CODE, status)
}),
),
),
Stream.provideService(HttpClient.TracerDisabledWhen, () => true),
Stream.provideService(HttpClient.TracerPropagationEnabled, false),
Stream.provideService(References.TracerEnabled, false),
)
}
function finalize(span: Span, state: State, scopeExit: Exit.Exit<unknown, unknown>) {
return Effect.gen(function* () {
if (!state.terminal) {
state.terminal = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
? Exit.failCause(scopeExit.cause)
: Exit.void
}
span.end(yield* Clock.currentTimeNanos, state.terminal)
})
}
export function safeUrl(url: URL) {
const safe = new URL(url)
safe.username = ""
safe.password = ""
safe.search = ""
safe.hash = ""
return safe.toString()
}
+117
View File
@@ -0,0 +1,117 @@
export * as LLMWebSocketTelemetry from "./websocket"
import { Cause, Clock, Effect, Exit, Option, References, Stream } from "effect"
import { ParentSpan, type Span } from "effect/Tracer"
import {
ATTR_ERROR_TYPE,
ATTR_NETWORK_PROTOCOL_NAME,
ATTR_NETWORK_TRANSPORT,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_SERVER_ADDRESS,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
ATTR_URL_PATH,
ATTR_URL_SCHEME,
} from "../semconv"
import { LLMError } from "../schema"
import { safeUrl } from "./http"
import { currentModelSpan } from "./context"
export { RequestIssued, ResponseChunkReceived } from "./http"
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
(cause) => !Cause.hasInterrupts(cause),
() => Effect.void,
)
export const stream = <A, R>(urlValue: string, source: Stream.Stream<A, LLMError, R>) =>
Stream.unwrap(
Effect.gen(function* () {
const parent = yield* currentModelSpan
if (!parent) return source
const url = URL.canParse(urlValue) ? new URL(urlValue) : undefined
const port = url?.port
? Number(url.port)
: url?.protocol === "wss:"
? 443
: url?.protocol === "ws:"
? 80
: undefined
const span = yield* Effect.makeSpan("websocket.exchange", {
kind: "client",
parent,
attributes: {
[ATTR_NETWORK_PROTOCOL_NAME]: "websocket",
[ATTR_NETWORK_TRANSPORT]: "tcp",
...(url
? {
[ATTR_SERVER_ADDRESS]: url.hostname,
...(port === undefined ? {} : { [ATTR_SERVER_PORT]: port }),
[ATTR_URL_FULL]: safeUrl(url),
[ATTR_URL_PATH]: url.pathname,
[ATTR_URL_SCHEME]: url.protocol.slice(0, -1),
}
: {}),
},
})
const state: State = {}
yield* Effect.addFinalizer((exit) => observe(finalize(span, state, exit)))
return observeStream(span, state, source)
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
() => Effect.succeed(source),
),
),
)
type State = { terminal?: Exit.Exit<void, unknown> }
function observeStream<A, R>(span: Span, state: State, source: Stream.Stream<A, LLMError, R>) {
const terminate = (exit: Exit.Exit<void, unknown>, type?: string) => {
if (state.terminal) return
state.terminal = exit
if (type) span.attribute(ATTR_ERROR_TYPE, type)
}
return source.pipe(
Stream.tapCause((cause) =>
observe(
Effect.gen(function* () {
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
if (Cause.hasInterruptsOnly(cause)) {
terminate(Exit.failCause(cause))
return
}
const type = error?.reason._tag ?? "unknown"
span.attribute(
ATTR_OPENCODE_ERROR_SOURCE,
error?.reason._tag === "Transport"
? "transport"
: error?.reason._tag === "InvalidProviderOutput"
? "protocol"
: "provider",
)
span.attribute(ATTR_OPENCODE_ERROR_STAGE, "websocket")
terminate(Exit.fail(new Error(type)), type)
}),
),
),
Stream.provideService(ParentSpan, span),
Stream.provideService(References.TracerEnabled, false),
)
}
function finalize(span: Span, state: State, scopeExit: Exit.Exit<unknown, unknown>) {
return Effect.gen(function* () {
if (!state.terminal) {
state.terminal = Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
? Exit.failCause(scopeExit.cause)
: Exit.void
}
span.end(yield* Clock.currentTimeNanos, state.terminal)
})
}
+1
View File
@@ -45,6 +45,7 @@ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
id: "fake",
operation: "chat",
body: {
schema: Schema.Struct({
body: Schema.String,
+23
View File
@@ -107,6 +107,29 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
it.effect("retains known HTTP status when an error response body cannot be read", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 500, http: { response: { status: 500 } } })
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(new Error("provider body secret"))
},
}),
{ status: 500 },
),
]),
),
),
)
it.effect("returns redacted diagnostics for rate limits", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -492,8 +492,6 @@ describe("Anthropic Messages route", () => {
),
)
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
}),
)
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Stream, Tracer } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
@@ -11,6 +11,7 @@ import { continuationRequest, nativeOpenAIResponsesContinuation } from "../conti
import { it } from "../lib/effect"
import { dynamicResponse, fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
import { ATTR_ERROR_TYPE, ATTR_NETWORK_PROTOCOL_NAME, ATTR_NETWORK_TRANSPORT, ATTR_URL_FULL } from "../../src/semconv"
const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
@@ -188,7 +189,19 @@ describe("OpenAI Responses route", () => {
it.effect("streams OpenAI Responses over WebSocket", () =>
Effect.gen(function* () {
const sent: string[] = []
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const opened: Array<{
readonly url: string
readonly authorization: string | undefined
readonly traceparent: string | undefined
}> = []
let closed = false
const deps = Layer.mergeAll(
Layer.succeed(
@@ -204,7 +217,11 @@ describe("OpenAI Responses route", () => {
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
opened.push({ url: input.url, authorization: input.headers.authorization })
opened.push({
url: input.url,
authorization: input.headers.authorization,
traceparent: input.headers.traceparent,
})
sent.push(message)
}),
messages: Stream.fromArray([
@@ -225,10 +242,12 @@ describe("OpenAI Responses route", () => {
),
prompt: "Say hello.",
}),
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), Effect.provideService(Tracer.Tracer, tracer))
expect(response.text).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
expect(opened).toEqual([
{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test", traceparent: undefined },
])
expect(closed).toBe(true)
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0])).toEqual({
@@ -237,6 +256,16 @@ describe("OpenAI Responses route", () => {
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
store: false,
})
const websocket = spans.find((span) => span.name === "websocket.exchange")
expect(websocket?.attributes.get(ATTR_NETWORK_PROTOCOL_NAME)).toBe("websocket")
expect(websocket?.attributes.get(ATTR_NETWORK_TRANSPORT)).toBe("tcp")
expect(websocket?.attributes.get(ATTR_URL_FULL)).toBe("wss://api.openai.test/v1/responses")
expect(websocket?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
expect(
websocket?.parent._tag === "Some" && websocket.parent.value._tag === "Span"
? websocket.parent.value.name
: undefined,
).toBe("chat gpt-4.1-mini")
}),
)
@@ -1374,10 +1403,6 @@ describe("OpenAI Responses route", () => {
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
)
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
}),
)
+713
View File
@@ -0,0 +1,713 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Deferred, Effect, Fiber, References, Stream, Tracer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, Usage } from "../src"
import * as Gemini from "../src/protocols/gemini"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { LLMClient } from "../src/route"
import { it } from "./lib/effect"
import { dynamicResponse, fixedResponse, runtimeLayer } from "./lib/http"
import { deltaChunk, usageChunk } from "./lib/openai-chunks"
import { sseEvents } from "./lib/sse"
import {
ATTR_ERROR_TYPE,
ATTR_GEN_AI_CONVERSATION_ID,
ATTR_GEN_AI_INPUT_MESSAGES,
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_OUTPUT_MESSAGES,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_MAX_TOKENS,
ATTR_GEN_AI_REQUEST_MODEL,
ATTR_GEN_AI_REQUEST_STREAM,
ATTR_GEN_AI_REQUEST_TEMPERATURE,
ATTR_GEN_AI_REQUEST_TOP_K,
ATTR_GEN_AI_REQUEST_TOP_P,
ATTR_GEN_AI_RESPONSE_FINISH_REASONS,
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
ATTR_HTTP_REQUEST_METHOD,
ATTR_HTTP_RESPONSE_STATUS_CODE,
ATTR_OPENCODE_ERROR_SOURCE,
ATTR_OPENCODE_ERROR_STAGE,
ATTR_OPENCODE_LLM_PROTOCOL,
ATTR_OPENCODE_LLM_ROUTE,
ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE,
ATTR_SERVER_ADDRESS,
ATTR_SERVER_PORT,
ATTR_URL_FULL,
GEN_AI_OPERATION_NAME_VALUE_CHAT,
} from "../src/semconv"
import { RequestIssued, ResponseChunkReceived, stream as instrument } from "../src/telemetry"
import { LLMHttpTelemetry } from "../src/telemetry/http"
import { LLMWebSocketTelemetry } from "../src/telemetry/websocket"
const ATTR_AGENT_STEP_INDEX = "test.agent.step.index"
const ATTR_AGENT_STEP_TRIGGER = "test.agent.step.trigger"
describe("GenAI telemetry", () => {
it.effect("tracks the OpenTelemetry GenAI registry projection", () =>
Effect.sync(() => {
expect({
ATTR_GEN_AI_OPERATION_NAME,
ATTR_GEN_AI_PROVIDER_NAME,
ATTR_GEN_AI_REQUEST_STREAM,
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK,
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS,
GEN_AI_OPERATION_NAME_VALUE_CHAT,
}).toMatchObject({
ATTR_GEN_AI_OPERATION_NAME: "gen_ai.operation.name",
ATTR_GEN_AI_PROVIDER_NAME: "gen_ai.provider.name",
ATTR_GEN_AI_REQUEST_STREAM: "gen_ai.request.stream",
ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK: "gen_ai.response.time_to_first_chunk",
ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS: "gen_ai.usage.reasoning.output_tokens",
GEN_AI_OPERATION_NAME_VALUE_CHAT: "chat",
})
}),
)
it.effect("records safe semantic convention attributes for a streaming model call", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const usage = {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 1 },
}
const model = OpenAIChat.route
.with({
endpoint: {
baseURL: "https://api.openai.test/v1",
query: { api_key: "secret-key", key: "short-key", sig: "signed-value" },
},
})
.model({ id: "gpt-4o-mini" })
let traceparent: string | undefined
const request = LLM.request({
model,
system: "secret system prompt",
prompt: "secret user prompt",
generation: { maxTokens: 20, temperature: 0, topP: 0.9, topK: 40 },
})
const response = yield* Effect.useSpan("invoke_agent build", (agent) =>
LLMClient.generate(request).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
traceparent = input.request.headers.traceparent
return input.respond(
sseEvents(
deltaChunk({ role: "assistant", content: "Hello" }),
deltaChunk({ content: " there" }),
deltaChunk({}, "stop"),
usageChunk(usage),
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
Effect.annotateSpans({
[ATTR_GEN_AI_CONVERSATION_ID]: "session-1",
[ATTR_AGENT_STEP_INDEX]: 1,
[ATTR_AGENT_STEP_TRIGGER]: "input",
}),
Effect.withParentSpan(agent),
),
).pipe(Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(response.usage).toEqual(
new Usage({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
reasoningTokens: 1,
totalTokens: 7,
providerMetadata: { openai: usage },
}),
)
expect(span?.kind).toBe("client")
expect(Object.fromEntries(span?.attributes ?? [])).toMatchObject({
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT,
[ATTR_GEN_AI_PROVIDER_NAME]: "openai",
[ATTR_GEN_AI_REQUEST_MODEL]: "gpt-4o-mini",
[ATTR_GEN_AI_REQUEST_STREAM]: true,
[ATTR_GEN_AI_REQUEST_MAX_TOKENS]: 20,
[ATTR_GEN_AI_REQUEST_TEMPERATURE]: 0,
[ATTR_GEN_AI_REQUEST_TOP_P]: 0.9,
[ATTR_GEN_AI_REQUEST_TOP_K]: 40,
[ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: ["stop"],
[ATTR_GEN_AI_USAGE_INPUT_TOKENS]: 5,
[ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: 2,
[ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: 1,
[ATTR_GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: 1,
[ATTR_GEN_AI_CONVERSATION_ID]: "session-1",
[ATTR_AGENT_STEP_INDEX]: 1,
[ATTR_AGENT_STEP_TRIGGER]: "input",
[ATTR_SERVER_ADDRESS]: "api.openai.test",
[ATTR_SERVER_PORT]: 443,
[ATTR_OPENCODE_LLM_ROUTE]: "openai-chat",
[ATTR_OPENCODE_LLM_PROTOCOL]: "openai-chat",
})
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBeNumber()
expect(span?.attributes.has(ATTR_GEN_AI_SYSTEM_INSTRUCTIONS)).toBe(false)
expect(span?.attributes.has(ATTR_GEN_AI_INPUT_MESSAGES)).toBe(false)
expect(span?.attributes.has(ATTR_GEN_AI_OUTPUT_MESSAGES)).toBe(false)
expect(ancestorNames(span)).toContain("invoke_agent build")
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(http?.name).toBe("POST")
expect(http?.attributes.get(ATTR_HTTP_RESPONSE_STATUS_CODE)).toBe(200)
expect(http?.attributes.get(ATTR_SERVER_PORT)).toBe(443)
expect(http?.attributes.get(ATTR_URL_FULL)).toStartWith("https://api.openai.test/")
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("?")
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("secret-key")
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("short-key")
expect(http?.attributes.get(ATTR_URL_FULL)).not.toContain("signed-value")
expect(ancestorNames(http)).toContain("chat gpt-4o-mini")
expect(
span?.status._tag === "Ended" && http?.status._tag === "Ended"
? span.status.endTime >= http.status.endTime
: false,
).toBeTrue()
expect(traceparent?.split("-")[1]).toBe(http?.traceId)
expect(traceparent?.split("-")[2]).toBe(http?.spanId)
}),
)
it.effect("uses the last reported usage when finish omits it", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const request = LLM.request({ model, prompt: "secret" })
yield* instrument(
request,
Stream.fromIterable([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 17, outputTokens: 9 } }),
LLMEvent.finish({ reason: "stop" }),
]),
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(17)
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS)).toBe(9)
}),
)
it.effect("preserves the model stream when span creation defects", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "broken-tracer-model" })
const events = [LLMEvent.finish({ reason: "stop" })]
const tracer = Tracer.make({
span() {
throw new Error("broken tracer")
},
})
const result = yield* instrument(LLM.request({ model, prompt: "secret" }), Stream.fromIterable(events)).pipe(
Stream.runCollect,
Effect.provideService(Tracer.Tracer, tracer),
)
expect(Array.from(result)).toEqual(events)
}),
)
it.effect("respects disabled tracing", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "disabled-model" })
const events = [LLMEvent.finish({ reason: "stop" })]
const result = yield* instrument(LLM.request({ model, prompt: "secret" }), Stream.fromIterable(events)).pipe(
Stream.runCollect,
Effect.provideService(References.TracerEnabled, false),
Effect.provideService(Tracer.Tracer, tracer),
)
expect(Array.from(result)).toEqual(events)
expect(spans).toHaveLength(0)
}),
)
it.effect("does not create transport spans beneath a disabled model span", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "disabled-transport-model" })
yield* Effect.useSpan("parent", () =>
LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "stop")))),
Effect.withTracerEnabled(false),
),
).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(spans.map((span) => span.name)).toEqual(["parent"])
}),
)
it.effect("requires an explicit model span for transport instrumentation", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
yield* Effect.useSpan("ambient", () =>
Effect.all(
[
LLMHttpTelemetry.stream(HttpClientRequest.post("https://example.test/path"), () => Stream.empty).pipe(
Stream.runDrain,
),
LLMWebSocketTelemetry.stream("wss://example.test/path", Stream.empty).pipe(Stream.runDrain),
],
{ discard: true },
),
).pipe(Effect.provideService(Tracer.Tracer, tracer))
expect(spans.map((span) => span.name)).toEqual(["ambient"])
}),
)
it.effect("does not attribute downstream consumer failures to transports", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "consumer-failure-model" })
const failure = new Error("consumer failed")
const error = yield* LLMClient.stream(LLM.request({ model, prompt: "secret" })).pipe(
Stream.runForEach(() => Effect.fail(failure)),
Effect.provide(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))),
Effect.flip,
Effect.provideService(Tracer.Tracer, tracer),
)
expect(error).toBe(failure)
const modelSpan = spans.find((span) => span.name === "chat consumer-failure-model")
const httpSpan = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(modelSpan?.attributes.get(ATTR_ERROR_TYPE)).toBe("incomplete_response")
expect(httpSpan?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
expect(httpSpan?.status._tag === "Ended" && httpSpan.status.exit._tag).toBe("Success")
}),
)
it.effect("closes a model span when its stream fiber is interrupted", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const started = yield* Deferred.make<void>()
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "interrupted-model" })
const source = Stream.concat(
Stream.fromEffect(Deferred.succeed(started, undefined).pipe(Effect.as(LLMEvent.stepStart({ index: 0 })))),
Stream.never,
)
yield* Effect.gen(function* () {
const fiber = yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
Stream.runDrain,
Effect.forkChild,
)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat interrupted-model")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("canceled")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
}),
)
it.effect("measures first-chunk latency from request issuance", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "timing-model" })
const source = Stream.concat(
Stream.fromEffect(
Effect.gen(function* () {
const requestIssued = yield* RequestIssued
if (requestIssued) yield* requestIssued(yield* Clock.currentTimeNanos)
yield* TestClock.adjust("250 millis")
const firstChunk = yield* ResponseChunkReceived
if (firstChunk) yield* firstChunk
return LLMEvent.stepStart({ index: 0 })
}),
),
Stream.succeed(LLMEvent.finish({ reason: "stop" })),
)
yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
Stream.runDrain,
Effect.provideService(Tracer.Tracer, tracer),
)
const span = spans.find((span) => span.name === "chat timing-model")
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBe(0.25)
}),
)
it.effect("omits first-chunk latency when request issuance is unavailable", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "unknown-origin-model" })
const source = Stream.fromIterable([LLMEvent.finish({ reason: "stop" })])
yield* instrument(LLM.request({ model, prompt: "secret" }), source).pipe(
Stream.runDrain,
Effect.provideService(Tracer.Tracer, tracer),
)
const span = spans.find((span) => span.name === "chat unknown-origin-model")
expect(span?.attributes.has(ATTR_GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK)).toBeFalse()
}),
)
it.effect("uses the model provider and route operation identity", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ provider: "xai", endpoint: { baseURL: "https://api.x.ai/v1" } })
.model({ id: "grok" })
yield* instrument(
LLM.request({ model, prompt: "secret" }),
Stream.fromIterable([LLMEvent.finish({ reason: "stop" })]),
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat grok")
expect(span?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("xai")
expect(span?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("chat")
yield* instrument(
LLM.request({ model: Gemini.route.model({ id: "gemini-test" }), prompt: "secret" }),
Stream.fromIterable([LLMEvent.finish({ reason: "stop" })]),
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
const gemini = spans.find((span) => span.name === "generate_content gemini-test")
expect(gemini?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("google")
expect(gemini?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("generate_content")
}),
)
it.effect("finalizes duplicate terminal events once", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "duplicate-terminal-model" })
const usage = new Usage({ inputTokens: 5, outputTokens: 2 })
const duplicateUsage = new Usage({ inputTokens: 99, outputTokens: 99 })
yield* instrument(
LLM.request({ model, prompt: "secret" }),
Stream.fromIterable([
LLMEvent.stepFinish({ index: 0, reason: "stop", usage }),
LLMEvent.finish({ reason: "stop", usage }),
LLMEvent.finish({ reason: "length", usage: duplicateUsage }),
]),
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat duplicate-terminal-model")
expect(span?.attributes.get(ATTR_GEN_AI_RESPONSE_FINISH_REASONS)).toEqual(["stop"])
expect(span?.attributes.get(ATTR_GEN_AI_USAGE_INPUT_TOKENS)).toBe(5)
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Success")
}),
)
it.effect("marks error finish reasons as provider failures", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "error-finish-model" })
yield* instrument(
LLM.request({ model, prompt: "secret" }),
Stream.succeed(LLMEvent.finish({ reason: "error" })),
).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat error-finish-model")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider_error")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
}),
)
it.live("propagates the transport span to provider requests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const received: { traceparent?: string; b3?: string } = {}
const server = Bun.serve({
port: 0,
fetch(request) {
received.traceparent = request.headers.get("traceparent") ?? undefined
received.b3 = request.headers.get("b3") ?? undefined
return new Response(
sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")),
{
headers: { "content-type": "text/event-stream" },
},
)
},
})
return { received, server }
}),
({ received, server }) =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: new URL("v1", server.url).toString() } })
.model({ id: "gpt-4o-mini" })
yield* LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
Effect.provide(runtimeLayer(FetchHttpClient.layer)),
Effect.withSpan("invoke_agent build"),
Effect.provideService(Tracer.Tracer, tracer),
)
const http = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
const traceparent = received.traceparent?.split("-")
expect(traceparent?.[1]).toBe(http?.traceId)
expect(traceparent?.[2]).toBe(http?.spanId)
expect(received.b3).toStartWith(`${http?.traceId}-${http?.spanId}-`)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("marks structured provider failures with safe span errors", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "secret" })).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "overloaded", message: "try later" }))),
Effect.provideService(Tracer.Tracer, tracer),
)
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(response.finishReason).toBe("error")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("provider_error")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("response")
expect(span?.attributes.has(ATTR_GEN_AI_RESPONSE_FINISH_REASONS)).toBeFalse()
expect(span?.status._tag).toBe("Ended")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
expect(spanFailure(span)?.message).toBe("provider_error")
expect(spanFailure(span)?.message).not.toContain("try later")
}),
)
it.effect("records request compilation failures only on the model span", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
yield* LLMClient.generate(
LLM.request({
model,
messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })],
}),
).pipe(Effect.provide(fixedResponse("")), Effect.flip, Effect.provideService(Tracer.Tracer, tracer))
const span = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(span?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("request")
expect(span?.attributes.get(ATTR_OPENCODE_ERROR_STAGE)).toBe("compile")
expect(span?.status._tag === "Ended" && span.status.exit._tag).toBe("Failure")
expect(spans.some((span) => span.name === "LLM.compile")).toBeFalse()
}),
)
it.effect("marks HTTP failures and incomplete model streams", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
.model({ id: "gpt-4o-mini" })
const request = LLM.request({ model, prompt: "secret" })
yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse("bad request", { status: 400 })),
Effect.flip,
Effect.provideService(Tracer.Tracer, tracer),
)
const failed = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(failed?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
expect(failed?.attributes.get(ATTR_OPENCODE_ERROR_SOURCE)).toBe("provider")
expect(failed?.attributes.get(ATTR_OPENCODE_PROVIDER_HTTP_STATUS_CODE)).toBe(400)
expect(failed?.status._tag === "Ended" && failed.status.exit._tag).toBe("Failure")
expect(spanFailure(failed)?.message).toBe("InvalidRequest")
expect(spanFailure(failed)?.message).not.toContain("bad request")
const failedHttp = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(failedHttp?.attributes.get(ATTR_ERROR_TYPE)).toBe("InvalidRequest")
expect(spanFailure(failedHttp)?.message).toBe("InvalidRequest")
spans.length = 0
yield* LLMClient.stream(request).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(fixedResponse(sseEvents(deltaChunk({ role: "assistant", content: "Hello" })))),
Effect.provideService(Tracer.Tracer, tracer),
)
const canceled = spans.find((span) => span.name === "chat gpt-4o-mini")
expect(canceled?.attributes.get(ATTR_ERROR_TYPE)).toBe("incomplete_response")
expect(canceled?.status._tag === "Ended" && canceled.status.exit._tag).toBe("Failure")
const canceledHttp = spans.find((span) => span.attributes.get(ATTR_HTTP_REQUEST_METHOD) === "POST")
expect(canceledHttp?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
expect(canceledHttp?.status._tag === "Ended" && canceledHttp.status.exit._tag).toBe("Success")
}),
)
})
function ancestorNames(span: Tracer.NativeSpan | undefined) {
const names: string[] = []
let current = span?.parent._tag === "Some" ? span.parent.value : undefined
while (current?._tag === "Span") {
names.push(current.name)
current = current.parent._tag === "Some" ? current.parent.value : undefined
}
return names
}
function spanFailure(span: Tracer.NativeSpan | undefined) {
if (span?.status._tag !== "Ended" || span.status.exit._tag !== "Failure") return
const failure = Cause.squash(span.status.exit.cause)
return failure instanceof Error ? failure : undefined
}
+1 -1
View File
@@ -84,8 +84,8 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/cli": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
+4 -6
View File
@@ -3,8 +3,6 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -63,7 +61,9 @@ export const AttachCommand = cmd({
return
}
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const credentials = { password: args.password, username: args.username }
const headers = ServerAuth.headers(credentials)
const endpoint = ServerAuth.endpoint(args.url, credentials)
const config = await TuiConfig.get()
try {
@@ -84,9 +84,7 @@ export const AttachCommand = cmd({
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
server: { endpoint },
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
+3 -6
View File
@@ -8,8 +8,6 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { ServerAuth } from "@/server/auth"
import { validateSession } from "../tui/validate-session"
@@ -135,6 +133,7 @@ export const TuiThreadCommand = cmd({
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
const headers = external ? ServerAuth.headers() : undefined
const url = (await client.call("server", network)).url
const endpoint = external ? ServerAuth.endpoint(url) : { url }
try {
await validateSession({
@@ -157,11 +156,9 @@ export const TuiThreadCommand = cmd({
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
api: OpenCode.make({ baseUrl: url, headers }),
server: { endpoint },
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
+10
View File
@@ -1,6 +1,7 @@
export * as ServerAuth from "./auth"
import { ConfigService } from "@/effect/config-service"
import { Service } from "@opencode-ai/client/effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
@@ -46,3 +47,12 @@ export function headers(credentials?: Credentials) {
if (!authorization) return undefined
return { Authorization: authorization }
}
export function endpoint(url: string, credentials?: Credentials): Service.Endpoint {
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return {
url,
auth: password ? { type: "basic", username, password } : undefined,
}
}
@@ -20,6 +20,10 @@ describe("ServerAuth", () => {
expect(ServerAuth.header()).toBeUndefined()
expect(ServerAuth.headers()).toBeUndefined()
expect(ServerAuth.endpoint("http://localhost:4096")).toEqual({
url: "http://localhost:4096",
auth: undefined,
})
})
test("defaults to the opencode username", () => {
@@ -47,6 +51,10 @@ describe("ServerAuth", () => {
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
})
expect(ServerAuth.endpoint("https://example.test", { password: "cli-secret", username: "bob" })).toEqual({
url: "https://example.test",
auth: { type: "basic", username: "bob", password: "cli-secret" },
})
})
test("validates decoded credentials against effect config", () => {
+7 -3
View File
@@ -1,8 +1,10 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
type Services = Layer.Success<ReturnType<typeof createEmbeddedRoutes>>
export const create = Effect.fn("OpenCode.create")(function* () {
const runtime = yield* Effect.acquireRelease(
@@ -12,7 +14,9 @@ export const create = Effect.fn("OpenCode.create")(function* () {
const context = yield* runtime.contextEffect
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
const handler = HttpEffect.toWebHandlerWith<Services, HttpServerRequest.HttpServerRequest | Scope.Scope>(context)(
router.asHttpEffect(),
)
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
preconnect: () => undefined,
}) satisfies typeof globalThis.fetch
+19 -19
View File
@@ -11,6 +11,7 @@ import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { requestRef, type LocationServices } from "../location"
import { ServerObservability } from "../observability"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
@@ -47,28 +48,27 @@ export const formLocationLayer = Layer.effect(
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
const location = yield* Effect.gen(function* () {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) {
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
}
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
})
}
}).pipe(Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")))
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
),
Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)),
)
}),
)
@@ -10,6 +10,7 @@ import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { ServerObservability } from "../observability"
import type { LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
@@ -39,28 +40,27 @@ export const sessionLocationLayer = Layer.effect(
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
const location = yield* Effect.gen(function* () {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
),
}).pipe(
Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")),
)
return yield* effect.pipe(Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)))
}),
)
}),
+33
View File
@@ -0,0 +1,33 @@
export * as ServerObservability from "./observability"
import { Cause, Effect, Layer, Option } from "effect"
import { HttpMiddleware } from "effect/unstable/http"
export const httpTracingDisabled = Layer.succeed(HttpMiddleware.TracerDisabledWhen, () => true)
export function locationLayer<A, E, R>(layer: Layer.Layer<A, E, R>, sessionID: string) {
return layer.pipe(Layer.tapCause((cause) => locationFailure(cause, sessionID, "load")))
}
export function locationFailure(cause: Cause.Cause<unknown>, sessionID: string, phase: "resolve" | "load") {
if (Cause.hasInterruptsOnly(cause)) return Effect.void
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
const log = error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError"
? Effect.logWarning
: Effect.logError
return log("Failed to resolve Session location", cause).pipe(
Effect.annotateLogs({
operation: "session.location",
phase,
sessionID,
errorType: errorType(cause),
}),
)
}
function errorType(cause: Cause.Cause<unknown>) {
const error = Option.getOrUndefined(Cause.findErrorOption(cause))
if (error && typeof error === "object" && "_tag" in error && typeof error._tag === "string") return error._tag
const failure = Cause.squash(cause)
return failure instanceof Error ? failure.name : "unknown"
}
+3 -1
View File
@@ -56,7 +56,9 @@ function bind(hostname: string, port: number, password: string) {
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}).pipe(
Layer.flatMap((context) =>
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger).pipe(
Layer.provide(Layer.succeedContext(context)),
),
),
Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
),
+3 -1
View File
@@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerObservability } from "./observability"
import { ServerInfo } from "./server-info"
const applicationServices = LayerNode.group([
@@ -90,6 +91,7 @@ function makeRoutes<AuthError, AuthServices>(
: AppNodeBuilder.build(applicationServices, replacements)
return serviceLayer.pipe(
Layer.provideMerge(Observability.layer),
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
@@ -104,7 +106,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
Layer.provide(Observability.layer),
Layer.merge(ServerObservability.httpTracingDisabled),
HttpRouter.provideRequest(requestServices),
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { NodeServices } from "@effect/platform-node"
import { Context, Effect, Layer, References } from "effect"
import { HttpMiddleware, HttpServer, HttpServerRequest } from "effect/unstable/http"
test("route construction retains the server tracing policy", async () => {
const database = process.env.OPENCODE_DB
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT
process.env.OPENCODE_DB = ":memory:"
try {
const { createEmbeddedRoutes } = await import("../src/routes")
await Effect.gen(function* () {
const context = yield* Layer.build(
createEmbeddedRoutes().pipe(
Layer.provide(HttpServer.layerServices),
Layer.provide(NodeServices.layer),
),
)
const request = HttpServerRequest.fromWeb(new Request("http://opencode.local/api/session"))
expect(Context.get(context, References.TracerEnabled)).toBeFalse()
expect(Context.get(context, HttpMiddleware.TracerDisabledWhen)(request)).toBeTrue()
}).pipe(Effect.scoped, Effect.runPromise)
} finally {
if (database === undefined) delete process.env.OPENCODE_DB
else process.env.OPENCODE_DB = database
if (endpoint === undefined) delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT
else process.env.OTEL_EXPORTER_OTLP_ENDPOINT = endpoint
}
})
+1
View File
@@ -176,6 +176,7 @@ export default defineConfig({
"config",
"providers",
"network",
"observability",
"enterprise",
"troubleshooting",
{
@@ -0,0 +1,152 @@
---
title: OpenTelemetry
description: Export OpenCode logs and traces to an OpenTelemetry backend such as Dash0.
---
OpenCode can export application logs and traces through OTLP over HTTP. Export is enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` is present in the environment at process startup.
---
## Signal support
| Signal | Support | Details |
| ------ | ------- | --------------------------------------------------------------------------------------------------- |
| Logs | Yes | OpenCode application logs are exported automatically. The default minimum level is `INFO`. |
| Traces | Yes | OpenCode exports application spans and OpenTelemetry GenAI spans for V2 agent and model operations. |
OpenCode appends `/v1/logs` and `/v1/traces` to the configured endpoint. Set a base OTLP/HTTP endpoint without a signal path and without a trailing slash.
:::note
OpenCode supports the shared `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and `OTEL_RESOURCE_ATTRIBUTES` variables. Signal-specific endpoint variables and OTLP/gRPC are not currently supported.
:::
---
## Dash0
Create an authorization token with ingest permissions in Dash0, then set the following variables in the environment that starts OpenCode:
```bash
export DASH0_AUTH_TOKEN="api_key"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingress.us-west-2.aws.dash0.com"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${DASH0_AUTH_TOKEN}"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=development"
opencode
```
Use the endpoint for your Dash0 region if it differs from the example. OpenCode sends telemetry directly to:
```text
https://ingress.us-west-2.aws.dash0.com/v1/logs
https://ingress.us-west-2.aws.dash0.com/v1/traces
```
To route telemetry to a specific [Dash0 dataset](https://www.dash0.com/docs/dash0/miscellaneous/glossary/datasets), add its header to the comma-separated header value:
```bash
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer ${DASH0_AUTH_TOKEN},Dash0-Dataset=production"
```
---
### AI model spans
The V2 Session runner emits an `invoke_agent <agent>` span around each agent turn, from initial input promotion until the Session would become idle. Each `chat <model>` client span is one model step beneath that turn, and `execute_tool <name>` spans cover local tool execution. Provider HTTP response streams and WebSocket connections are client spans beneath their model calls. Spans include safe OpenTelemetry GenAI attributes for the agent, configured provider identity, requested model, generation settings, response timing, finish reason, token usage, cache usage, reasoning usage, conversation ID, tool identity, and transport endpoint. First-chunk latency is measured from request issuance to the first provider transport chunk or WebSocket message.
Agent spans record input promotion, provider retry decisions, hosted-tool activity, and compaction lifecycle as span events. Subagent tool spans identify the child Session and target agent. Foreground child agents remain nested, while background child traces link to the spawning tool span and include `opencode.session.parent.id` for searchable correlation.
Each new turn also links to the previous turn observed for that Session in the current process. This process-local chain lets linked-trace navigation walk backward through recent conversation turns without keeping one long-lived trace open or adding durable turn state.
Failed spans use existing typed error categories, error source and stage, HTTP status, transport kind, retry decision, and provider request ID attributes when available. Built-in provider stream errors preserve structured provider codes without inferring retry policy from message text. Raw provider bodies, exception messages, prompt content, and tool output are excluded from spans.
Model and tool spans include `opencode.agent.step.index` and `opencode.agent.step.trigger`. Trigger values distinguish calls caused by new `input`, a `tool_result`, a provider `retry`, `compaction`, or explicit `resume`; promoted inputs also include their `steer` or `queue` delivery mode.
OpenCode disables Effect tracing by default and enables it only at explicit GenAI operation boundaries. Unrelated spans such as startup, configuration, request lowering, and database operations are not recorded.
Prompt admission and control-plane HTTP requests are not traced. Admission, Session placement, and execution lifecycle failures emit sanitized structured logs with their Session, operation or phase, and stable error type. Provider HTTP and WebSocket requests do not receive trace propagation headers.
Prompt content, model output, system instructions, tool definitions, tool arguments, headers, and credentials are not recorded on these spans.
The `experimental.openTelemetry` configuration option only controls AI SDK telemetry on the legacy Session implementation. V2 GenAI spans are exported whenever OTLP tracing is configured.
### Navigate conversations
Each agent turn is exported as a separate trace.
1. In Dash0, filter by `gen_ai.conversation.id = <session-id>` and `gen_ai.operation.name = invoke_agent`, then sort by start time.
2. List turn traces with the Dash0 CLI:
```bash
dash0 spans query \
--dataset production \
--from now-4h \
--filter "gen_ai.conversation.id is <session-id>" \
--filter "gen_ai.operation.name is invoke_agent" \
--column timestamp \
--column "span name" \
--column "trace id" \
--column "span links"
```
3. Follow links from the newest turn:
```bash
dash0 traces get <trace-id> \
--dataset production \
--from now-4h \
--follow-span-links
```
4. Use **Links to** for the previous turn and **Linked from** for the next. The oldest observed turn has no `previous_turn` link.
Foreground subagents are child spans in the spawning turn. Background subagents use linked traces with `opencode.link.type=subagent`; turn links use `opencode.link.type=previous_turn`. Links are process-local, so use `gen_ai.conversation.id` across restarts.
---
### Resource attributes
Every exported signal has these resource attributes:
| Attribute | Value |
| ----------------------------- | ---------------------------------------------------------------------------- |
| `service.name` | `opencode` |
| `service.version` | The OpenCode version |
| `deployment.environment.name` | `OTEL_RESOURCE_ATTRIBUTES` value, or the OpenCode release channel by default |
| `opencode.client` | The client type, such as `cli` |
| `opencode.run` | A unique ID for the current process |
| `service.instance.id` | The same process ID as `opencode.run` |
Add more comma-separated attributes with `OTEL_RESOURCE_ATTRIBUTES`. Percent-encode commas and equals signs inside names or values.
```bash
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=development,service.namespace=developer-tools,team.name=platform"
```
`service.name`, `service.version`, `opencode.client`, `opencode.run`, and `service.instance.id` are set by OpenCode and cannot be overridden.
---
### Verify
1. Start OpenCode with the environment variables set.
2. Run a prompt and keep the process open for a few seconds so batched telemetry can be exported.
3. In Dash0, select a time range that includes the test.
4. Search logs and traces for `service.name = opencode`.
5. Use `opencode.run` to correlate records from one OpenCode process.
Logs emitted inside an active span include the trace and span IDs, allowing Dash0 to correlate those log records with the trace.
---
### Troubleshoot
- Confirm the endpoint is the base regional URL and does not end in `/v1/logs`, `/v1/traces`, or `/`.
- Confirm the token has ingest permissions and the `Authorization` value starts with `Bearer `.
- Set the environment variables before OpenCode starts. Restart a running OpenCode process after changing them.
- Set `OPENCODE_LOG_LEVEL=DEBUG` to export debug logs while investigating. This can substantially increase volume and may expose more sensitive details.
- `experimental.openTelemetry` only affects the legacy Session implementation; V2 GenAI spans do not require it.
- Check the local OpenCode log for exporter errors. See [Troubleshooting](/docs/troubleshooting#logs).
- Configure proxy and certificate settings as described in [Network](/docs/network) when direct HTTPS access to Dash0 is unavailable.
Short-lived commands can exit before all batched spans are sent. Prefer verifying with the TUI or a long-running `opencode serve` process.