feat(observability): add v2 genai tracing
This commit is contained in:
parent
e3a39b214f
commit
0271872c09
42 changed files with 3150 additions and 373 deletions
|
|
@ -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: [] })
|
||||
|
|
|
|||
267
packages/core/src/observability/agent.ts
Normal file
267
packages/core/src/observability/agent.ts
Normal 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")))
|
||||
}
|
||||
121
packages/core/src/observability/http.ts
Normal file
121
packages/core/src/observability/http.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
57
packages/core/src/observability/semconv.ts
Normal file
57
packages/core/src/observability/semconv.ts
Normal 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"
|
||||
29
packages/core/src/observability/session.ts
Normal file
29
packages/core/src/observability/session.ts
Normal 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 }
|
||||
}
|
||||
125
packages/core/src/observability/tool.ts
Normal file
125
packages/core/src/observability/tool.ts
Normal 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)),
|
||||
}
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
),
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 _ = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
".": "./src/index.ts",
|
||||
"./route": "./src/route/index.ts",
|
||||
"./provider": "./src/provider.ts",
|
||||
"./semconv": "./src/semconv.ts",
|
||||
"./providers": "./src/providers/index.ts",
|
||||
"./provider-package": "./src/provider-package.ts",
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
|
@ -254,14 +254,14 @@ const ANTHROPIC_BREAKPOINT_CAP = 4
|
|||
const EPHEMERAL_5M = { type: "ephemeral" as const }
|
||||
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
|
||||
|
||||
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
|
||||
const cacheControl = (breakpoints: Breakpoints, cache: CacheHint | undefined) => {
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
|
|
@ -272,7 +272,7 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
|||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: inputSchema,
|
||||
|
|
@ -400,7 +400,7 @@ const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number)
|
|||
|
||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||
message: LLMRequest["messages"][number],
|
||||
breakpoints: Cache.Breakpoints,
|
||||
breakpoints: Breakpoints,
|
||||
) {
|
||||
const content = yield* ProviderShared.systemUpdateText("Anthropic Messages", message)
|
||||
return {
|
||||
|
|
@ -415,7 +415,7 @@ const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUp
|
|||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: Cache.Breakpoints,
|
||||
breakpoints: Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
|
||||
|
|
@ -541,7 +541,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
|||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const breakpoints = newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
55
packages/llm/src/semconv.ts
Normal file
55
packages/llm/src/semconv.ts
Normal 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"
|
||||
327
packages/llm/src/telemetry.ts
Normal file
327
packages/llm/src/telemetry.ts
Normal 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 }
|
||||
}
|
||||
167
packages/llm/src/telemetry/http.ts
Normal file
167
packages/llm/src/telemetry/http.ts
Normal 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()
|
||||
}
|
||||
126
packages/llm/src/telemetry/websocket.ts
Normal file
126
packages/llm/src/telemetry/websocket.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -107,6 +107,29 @@ describe("RequestExecutor", () => {
|
|||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
it.effect("retains known HTTP status when an error response body cannot be read", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 500, http: { response: { status: 500 } } })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(new Error("provider body secret"))
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns redacted diagnostics for rate limits", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
|
|
|||
|
|
@ -492,8 +492,6 @@ describe("Anthropic Messages route", () => {
|
|||
),
|
||||
)
|
||||
|
||||
// Prefix the error type so consumers can distinguish overloads, rate
|
||||
// limits, and quota errors without parsing the message string.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Stream, Tracer } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
|
|
@ -11,6 +11,7 @@ import { continuationRequest, nativeOpenAIResponsesContinuation } from "../conti
|
|||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
import { ATTR_ERROR_TYPE, ATTR_NETWORK_PROTOCOL_NAME, ATTR_NETWORK_TRANSPORT, ATTR_URL_FULL } from "../../src/semconv"
|
||||
|
||||
const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
|
|
@ -188,7 +189,19 @@ describe("OpenAI Responses route", () => {
|
|||
it.effect("streams OpenAI Responses over WebSocket", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent: string[] = []
|
||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
span(options) {
|
||||
const span = new Tracer.NativeSpan(options)
|
||||
spans.push(span)
|
||||
return span
|
||||
},
|
||||
})
|
||||
const opened: Array<{
|
||||
readonly url: string
|
||||
readonly authorization: string | undefined
|
||||
readonly traceparent: string | undefined
|
||||
}> = []
|
||||
let closed = false
|
||||
const deps = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
|
|
@ -204,7 +217,11 @@ describe("OpenAI Responses route", () => {
|
|||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
opened.push({
|
||||
url: input.url,
|
||||
authorization: input.headers.authorization,
|
||||
traceparent: input.headers.traceparent,
|
||||
})
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
|
|
@ -225,10 +242,12 @@ describe("OpenAI Responses route", () => {
|
|||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))), Effect.provideService(Tracer.Tracer, tracer))
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||
expect(opened).toEqual([
|
||||
{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test", traceparent: undefined },
|
||||
])
|
||||
expect(closed).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(JSON.parse(sent[0])).toEqual({
|
||||
|
|
@ -237,6 +256,16 @@ describe("OpenAI Responses route", () => {
|
|||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
store: false,
|
||||
})
|
||||
const websocket = spans.find((span) => span.name === "websocket.exchange")
|
||||
expect(websocket?.attributes.get(ATTR_NETWORK_PROTOCOL_NAME)).toBe("websocket")
|
||||
expect(websocket?.attributes.get(ATTR_NETWORK_TRANSPORT)).toBe("tcp")
|
||||
expect(websocket?.attributes.get(ATTR_URL_FULL)).toBe("wss://api.openai.test/v1/responses")
|
||||
expect(websocket?.attributes.has(ATTR_ERROR_TYPE)).toBeFalse()
|
||||
expect(
|
||||
websocket?.parent._tag === "Some" && websocket.parent.value._tag === "Span"
|
||||
? websocket.parent.value.name
|
||||
: undefined,
|
||||
).toBe("chat gpt-4.1-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -1374,10 +1403,6 @@ describe("OpenAI Responses route", () => {
|
|||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
||||
)
|
||||
|
||||
// Prefix the code so consumers see the failure mode, not just the
|
||||
// sometimes-generic provider message. The bare message alone meant
|
||||
// production errors like rate limits were indistinguishable from
|
||||
// unrelated stream failures.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
546
packages/llm/test/telemetry.test.ts
Normal file
546
packages/llm/test/telemetry.test.ts
Normal 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
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { Effect, Layer, Schema } from "effect"
|
|||
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { requestRef, type LocationServices } from "../location"
|
||||
import { ServerObservability } from "../observability"
|
||||
|
||||
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
FormLocationMiddleware,
|
||||
|
|
@ -47,28 +48,27 @@ export const formLocationLayer = Layer.effect(
|
|||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
const location = yield* Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
}
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
})
|
||||
}
|
||||
}).pipe(Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")))
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Effect, Layer, Schema } from "effect"
|
|||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { ServerObservability } from "../observability"
|
||||
import type { LocationServices } from "../location"
|
||||
|
||||
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
|
|
@ -39,28 +40,27 @@ export const sessionLocationLayer = Layer.effect(
|
|||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
const location = yield* Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row)
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
})
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.tapCause((cause) => ServerObservability.locationFailure(cause, sessionID, "resolve")),
|
||||
)
|
||||
|
||||
return yield* effect.pipe(Effect.provide(ServerObservability.locationLayer(locations.get(location), sessionID)))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
33
packages/server/src/observability.ts
Normal file
33
packages/server/src/observability.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export default defineConfig({
|
|||
"config",
|
||||
"providers",
|
||||
"network",
|
||||
"observability",
|
||||
"enterprise",
|
||||
"troubleshooting",
|
||||
{
|
||||
|
|
|
|||
118
packages/web/src/content/docs/observability.mdx
Normal file
118
packages/web/src/content/docs/observability.mdx
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue