feat(observability): add v2 genai tracing

This commit is contained in:
starptech 2026-07-08 18:02:31 +02:00
commit 0271872c09
42 changed files with 3150 additions and 373 deletions

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: [] })

View file

@ -0,0 +1,267 @@
export * as AgentTelemetry from "./agent"
import type { LLMEvent, Usage } from "@opencode-ai/llm"
import { Cause, Clock, Context, Effect, Exit } 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_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"
export const Current = Context.Reference<Span | undefined>("@opencode/AgentTelemetry/Current", {
defaultValue: () => undefined,
})
const FailureState = Context.Reference<{ stage?: string } | undefined>("@opencode/AgentTelemetry/FailureState", {
defaultValue: () => undefined,
})
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 errorType: (cause: unknown) => string },
effect: Effect.Effect<A, E, R>,
) =>
Effect.gen(function* () {
const traceParent = yield* SessionTelemetry.TraceParent
const failureState: { stage?: string } = {}
const span = yield* Effect.makeSpan("invoke_agent", {
kind: "internal",
parent: traceParent ?? undefined,
root: traceParent === null,
attributes: {
[ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT,
[ATTR_GEN_AI_CONVERSATION_ID]: input.sessionID,
},
}).pipe(
Effect.withTracerEnabled(true),
Effect.catchCause(() => Effect.succeed(undefined)),
)
if (!span) return yield* effect
return yield* effect.pipe(
Effect.onExit((exit) =>
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(() => input.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)))
}),
),
),
Effect.withParentSpan(span, { captureStackTrace: false }),
Effect.provideService(Current, span),
Effect.provideService(FailureState, failureState),
Effect.withTracerEnabled(false),
)
})
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 span = yield* Current
const parentSessionID = span?.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 } : {}),
}),
)
const traced = observed.pipe(Effect.withTracerEnabled(true))
if (!span) return yield* traced
return yield* traced.pipe(Effect.withParentSpan(span, { 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* Current
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* Current
if (span) yield* observe(Effect.sync(() => f(span)))
})
}
function classify(f: () => string) {
return Effect.sync(f).pipe(Effect.catchCause(() => Effect.succeed("unknown")))
}

View file

@ -0,0 +1,121 @@
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.Current) ??
(yield* AgentTelemetry.Current) ??
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 = ""
for (const key of safe.searchParams.keys()) {
if (/(?:api[-_]?key|token|secret|signature|credential|password|authorization|auth)/i.test(key))
safe.searchParams.set(key, "[REDACTED]")
}
safe.hash = ""
return safe.toString()
}

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"

View file

@ -0,0 +1,57 @@
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_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"

View file

@ -0,0 +1,29 @@
export * as SessionTelemetry from "./session"
import { Context, Effect, Option } from "effect"
import type { AnySpan } from "effect/Tracer"
import { ParentSpan } from "effect/Tracer"
// undefined inherits the ambient parent, null explicitly detaches background work.
export const TraceParent = Context.Reference<AnySpan | null | undefined>("@opencode/SessionTelemetry/TraceParent", {
defaultValue: () => undefined,
})
export function makeExecution<Key>() {
const parents = new Map<Key, AnySpan>()
const drain = <A, E, R>(key: Key, effect: Effect.Effect<A, E, R>) => {
const parent = parents.get(key)
const observed = effect.pipe(Effect.provideService(TraceParent, parent))
return parent === undefined ? 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 parent = override === null ? undefined : (override ?? ambient)
if (parent !== undefined && !parents.has(key)) parents.set(key, parent)
return yield* effect
})
const settled = (key: Key) => Effect.sync(() => parents.delete(key)).pipe(Effect.asVoid)
return { drain, resume, settled }
}

View file

@ -0,0 +1,125 @@
export * as ToolTelemetry from "./tool"
import { Cause, Clock, Context, Effect, Exit } 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_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 Current = Context.Reference<Span | undefined>("@opencode/ToolTelemetry/Current", {
defaultValue: () => undefined,
})
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* Current
const agentSpan = yield* AgentTelemetry.Current
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.hasInterruptsOnly(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.provideService(Current, span),
Effect.withTracerEnabled(false),
)
})
export const child = (input: { readonly agent: string; readonly sessionID: string }) =>
Effect.gen(function* () {
const span = yield* Current
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)),
}
})

View file

@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { Cause, DateTime, 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"
@ -42,7 +42,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"
@ -506,6 +506,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) {
@ -720,6 +731,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 {

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,
})

View file

@ -49,6 +49,7 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import { AgentTelemetry } from "../../observability/agent"
/**
* Runs one durable coding-agent Session until it settles.
@ -174,25 +175,28 @@ 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
const agent = yield* agents.select(session.agent)
const agent = yield* AgentTelemetry.stage("agent", agents.select(session.agent))
yield* AgentTelemetry.identify({ agent: agent.id, parentSessionID: session.parentID })
// 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),
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
let needsContinuation = false
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)
@ -200,16 +204,27 @@ 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 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 = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
: yield* AgentTelemetry.stage(
"tool_materialization",
tools.materialize({ permissions: agent.info?.permissions, model }),
)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@ -228,12 +243,17 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined,
})
// 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 }))
)
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 }),
)
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,
@ -251,65 +271,83 @@ const layer = Layer.effect(
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).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
}
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(request).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)
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
}
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.outputPaths ?? [],
settlement.error,
).pipe(
Effect.andThen(
settlement.error?.type === "permission.rejected"
? serialized(publisher.failAssistant(settlement.error)).pipe(
Effect.andThen(Effect.fail(new UserInterruptedError())),
)
: 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.outputPaths ?? [],
settlement.error,
),
).pipe(
Effect.andThen(
settlement.error?.type === "permission.rejected"
? serialized(publisher.failAssistant(settlement.error)).pipe(
Effect.andThen(Effect.fail(new UserInterruptedError())),
)
: Effect.void,
),
),
),
),
),
),
).pipe(FiberSet.run(toolFibers)),
)
}),
).pipe(FiberSet.run(toolFibers)),
)
}),
),
Effect.ensuring(serialized(publisher.flush())),
),
),
Effect.ensuring(serialized(publisher.flush())),
)
// Captures the end snapshot, diffs it against the step's start, and durably ends the
@ -323,16 +361,19 @@ 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,
cost: 0,
tokens: settlement.tokens,
snapshot: endSnapshot,
files,
}),
yield* AgentTelemetry.stage(
"step_publication",
serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,
tokens: settlement.tokens,
snapshot: endSnapshot,
files,
}),
),
)
})
@ -362,11 +403,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() &&
(agent.info?.steps === undefined || currentStep < agent.info.steps)
) {
const retryable = SessionRunnerRetry.isRetryable(llmFailure)
const hasOutput = publisher.hasRetryEvidence()
const stepLimited = agent.info?.steps !== undefined && currentStep >= agent.info.steps
if (retryable && !hasOutput && !stepLimited) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
@ -374,6 +414,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.
@ -441,16 +487,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())
@ -474,6 +521,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
@ -481,10 +529,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
@ -492,22 +554,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
@ -519,6 +592,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* () {
@ -530,8 +604,12 @@ 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
}
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
yield* AgentTelemetry.compactionFailed("manual")
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
@ -543,44 +621,56 @@ 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
const run = Effect.gen(function* () {
yield* AgentTelemetry.stage("compaction", runPendingCompaction(input.sessionID))
const hasSteer = yield* AgentTelemetry.stage("input", SessionInput.hasPending(db, input.sessionID, "steer"))
const hasQueue = hasSteer
? false
: yield* AgentTelemetry.stage("input", SessionInput.hasPending(db, input.sessionID, "queue"))
if (!input.force && !hasSteer && !hasQueue) return
yield* AgentTelemetry.stage("tool_recovery", 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) {
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, 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)
promotion = "steer"
needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
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"
}
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
}
})
return yield* AgentTelemetry.invoke(
{ sessionID: input.sessionID, errorType: (cause) => toSessionError(cause).type },
run,
)
})
return Service.of({ drain })

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,
})
})
}),
)

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)

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
@ -216,15 +217,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
},
),
}
}),
})

View file

@ -5,6 +5,7 @@ import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { PluginRuntime } from "../plugin/runtime"
import { ToolTelemetry } from "../observability/tool"
import { SessionSchema } from "../session/schema"
import { Tool } from "./tool"
@ -131,13 +132,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, prompt: { 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)))

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")),

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),

View file

@ -1,11 +1,23 @@
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_OPENCODE_CLIENT,
ATTR_OPENCODE_RUN,
ATTR_SERVICE_INSTANCE_ID,
ATTR_SERVICE_NAMESPACE,
} from "@opencode-ai/core/observability/semconv"
import { Deferred, Effect, 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 { it } from "../lib/effect"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
@ -20,8 +32,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 +43,87 @@ 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("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")
}),
)
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 _ = {

View file

@ -15,7 +15,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, Fiber, Layer, Stream } from "effect"
import { ATTR_ERROR_TYPE } from "@opencode-ai/core/observability/semconv"
import { Deferred, Effect, Fiber, Layer, Stream, Tracer } from "effect"
import { testEffect } from "./lib/effect"
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@ -293,6 +294,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: ["*"] }))
@ -308,12 +317,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()
}),
)

View file

@ -1,5 +1,11 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
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"
@ -39,7 +45,7 @@ import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { Effect, Layer, Tracer } from "effect"
import path from "node:path"
import { testEffect } from "./lib/effect"
@ -93,6 +99,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
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* () {
@ -108,7 +122,10 @@ const execution = Layer.effect(
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.updateService(Tracer.Tracer, () => tracer),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@ -149,6 +166,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 { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@ -185,6 +203,14 @@ 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")
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?.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(
(yield* db
.select({ type: EventTable.type })

View file

@ -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[] = []
@ -212,6 +226,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

View file

@ -8,9 +8,42 @@ 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,
EVENT_OPENCODE_COMPACTION_COMPLETED,
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"
@ -61,7 +94,7 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
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, Schema, Stream, Tracer } from "effect"
import { TestClock } from "effect/testing"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@ -259,6 +292,14 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[McpGuidance.node, mcpGuidance],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
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* () {
@ -274,7 +315,10 @@ const execution = Layer.effect(
awaitIdle: coordinator.awaitIdle,
})
}),
).pipe(Layer.provide(runnerLayer))
).pipe(
Layer.provide(runnerLayer),
Layer.updateService(Tracer.Tracer, () => tracer),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@ -338,6 +382,7 @@ const insertSession = (id: SessionV2.ID) =>
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
requests.length = 0
response = []
systemBaseline = "Initial context"
systemRemoved = false
@ -356,6 +401,7 @@ const setup = Effect.gen(function* () {
toolExecutionsReady = 5
activeToolExecutions = 0
maxActiveToolExecutions = 0
spans.length = 0
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
@ -643,6 +689,180 @@ 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, prompt: PromptInput.Prompt.make({ 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")
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(ancestorNames(agent)).toContain("SessionRunner.drain")
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(ancestorNames(tool)).toContain("invoke_agent")
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,
prompt: PromptInput.Prompt.make({ 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")
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)).toContain("invoke_agent")
}),
)
it.effect("distinguishes input and tool-result model calls", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: PromptInput.Prompt.make({ 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")
}),
)
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, prompt: PromptInput.Prompt.make({ 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?.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* () {
yield* setup
@ -1445,6 +1665,12 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(
spans
.filter((span) => span.name === "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>")
@ -3429,6 +3655,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 === "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)
}),
)
@ -3766,7 +3995,20 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.retry.scheduled.1")
expect(
spans
.find((span) => span.name === "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 === "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" }] },
@ -3826,8 +4068,26 @@ describe("SessionRunnerLLM", () => {
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
expect(
spans
.find((span) => span.name === "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 === "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" })
}
}),
)
@ -4310,3 +4570,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
}

View file

@ -1,5 +1,9 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Layer, Schema, Tracer } from "effect"
import {
ATTR_OPENCODE_SUBAGENT_AGENT_NAME,
ATTR_OPENCODE_SUBAGENT_SESSION_ID,
} from "@opencode-ai/core/observability/semconv"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -22,6 +26,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
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"
@ -30,6 +35,7 @@ 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 } }
let resumedWith: Tracer.AnySpan | undefined
const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID
@ -76,7 +82,11 @@ const executionNode = makeGlobalNode({
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: complete,
resume: (sessionID) =>
Effect.gen(function* () {
resumedWith = (yield* SessionTelemetry.TraceParent) ?? undefined
return yield* complete(sessionID)
}),
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
@ -171,6 +181,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,
@ -181,7 +199,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))
@ -191,6 +209,10 @@ 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)
expect(resumedWith?.spanId).toBe(span?.spanId)
const fallback = yield* settleTool(registry, {
sessionID: parent.id,
@ -255,6 +277,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)
resumedWith = undefined
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,
@ -265,7 +296,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" })
@ -274,6 +305,7 @@ describe("SubagentTool", () => {
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText)
expect(resumedWith).toBeUndefined()
}),
),
),

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")
}),
)

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",

View file

@ -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

View file

@ -1,6 +1,5 @@
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"
@ -9,8 +8,9 @@ import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { 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,
@ -38,7 +38,7 @@ export interface Route<Body, Prepared = unknown> {
readonly provider?: ProviderID
readonly protocol: ProtocolID
readonly endpoint: Endpoint<Body>
readonly auth: AuthDef
readonly auth: Auth
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
@ -83,7 +83,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>
}
@ -189,7 +189,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. */
@ -208,7 +208,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. */
@ -219,7 +219,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))
}
@ -341,22 +341,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)
@ -371,13 +373,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) {

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,24 +129,33 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
})),
),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request)
.pipe(
LLMHttpTelemetry.stream(
prepared.request,
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),
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),
),
),
),
),
),
)
}),
),
),
),
),
),
})

View file

@ -1,7 +1,8 @@
import { Cause, 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,30 @@ 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,
)
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)),
)
}),
),
)
},
})

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"

View file

@ -0,0 +1,327 @@
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_OPERATION_NAME_VALUE_CHAT,
GEN_AI_OPERATION_NAME_VALUE_GENERATE_CONTENT,
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 = {
ended: boolean
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 identity = telemetryIdentity(request)
const operation = identity.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]: identity.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 = { ended: false }
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 Stream.concat(
input.stream,
Stream.fromEffect(Effect.sync(() => (input.state.ended = true))).pipe(Stream.drain),
).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)) {
if (!terminate(Exit.void)) return
const attributes = finishAttributes(event)
for (const [key, value] of Object.entries(attributes)) input.span.attribute(key, value)
}
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.onEnd(Effect.sync(() => (input.state.ended = true))),
Stream.provideService(ParentSpan, input.span),
Stream.provideService(RequestIssued, (time) =>
Effect.sync(() => {
input.state.requestIssued ??= time
recordFirstChunk(input.span, input.state)
}),
),
Stream.provideService(
ResponseChunkReceived,
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 = state.ended
? "incomplete_response"
: Exit.isFailure(scopeExit) && Cause.hasInterruptsOnly(scopeExit.cause)
? "canceled"
: Exit.isFailure(scopeExit)
? causeErrorType(scopeExit.cause)
: "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 }),
}
}
function telemetryIdentity(request: LLMRequest) {
const operation =
request.model.route.protocol === "gemini"
? GEN_AI_OPERATION_NAME_VALUE_GENERATE_CONTENT
: GEN_AI_OPERATION_NAME_VALUE_CHAT
const provider =
request.model.provider === "google"
? "gcp.gemini"
: request.model.provider === "amazon-bedrock"
? "aws.bedrock"
: request.model.provider === "azure"
? "azure.ai.openai"
: request.model.provider === "xai"
? "x_ai"
: request.model.provider
return { operation, provider }
}

View file

@ -0,0 +1,167 @@
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 } 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"
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: Stream.Stream<A, LLMError, R>) =>
Stream.unwrap(
Effect.gen(function* () {
const parent = Option.getOrUndefined(yield* Effect.serviceOption(ParentSpan))
if (parent?._tag !== "Span") return source
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 = { ended: false, responseReceived: false }
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 = {
ended: boolean
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 Stream.concat(source, Stream.fromEffect(Effect.sync(() => (state.ended = true))).pipe(Stream.drain)).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) =>
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 = state.ended
? Exit.void
: Exit.isFailure(scopeExit)
? 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 = ""
for (const key of safe.searchParams.keys()) {
if (/(?:api[-_]?key|token|secret|signature|credential|password|authorization|auth)/i.test(key))
safe.searchParams.set(key, "[REDACTED]")
}
safe.hash = ""
return safe.toString()
}

View file

@ -0,0 +1,126 @@
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 { RequestIssued, safeUrl } from "./http"
export { 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 = Option.getOrUndefined(yield* Effect.serviceOption(ParentSpan))
if (parent?._tag !== "Span") 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 = { ended: false }
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 = { ended: 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 Stream.concat(source, Stream.fromEffect(Effect.sync(() => (state.ended = true))).pipe(Stream.drain)).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"
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 = state.ended
? Exit.void
: Exit.isFailure(scopeExit)
? Exit.failCause(scopeExit.cause)
: Exit.void
}
span.end(yield* Clock.currentTimeNanos, state.terminal)
})
}

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

View file

@ -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" }])
}),
)

View file

@ -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" }])
}),
)

View file

@ -0,0 +1,546 @@
import { describe, expect } from "bun:test"
import { Cause, Clock, Effect, References, Stream, Tracer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { FetchHttpClient } from "effect/unstable/http"
import { LLM, LLMEvent, Message, Usage } from "../src"
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"
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" } } })
.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("secret-key")
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).toBeUndefined()
}),
)
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("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 semantic convention provider 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))
expect(spans.find((span) => span.name === "chat grok")?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("x_ai")
}),
)
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.live("does not mutate provider request headers", () =>
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")
expect(http).toBeDefined()
expect(received.traceparent).toBeUndefined()
expect(received.b3).toBeUndefined()
}),
({ 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
}

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)),
)
}),
)

View file

@ -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)))
}),
)
}),

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"
}

View file

@ -29,6 +29,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"
const applicationServices = LayerNode.group([
Database.node,
@ -95,6 +96,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provide(auth),
Layer.provide(serviceLayer),
Layer.provide(Observability.layer),
Layer.merge(ServerObservability.httpTracingDisabled),
)
}

View file

@ -176,6 +176,7 @@ export default defineConfig({
"config",
"providers",
"network",
"observability",
"enterprise",
"troubleshooting",
{

View file

@ -0,0 +1,118 @@
---
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` span around each Session drain, a `chat <model>` client span around every model call, and an `execute_tool <name>` span around 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 normalized model response event.
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 include `opencode.session.parent.id` for correlation.
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.
---
### 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.