diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 3330c77f4a..56d8dba449 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -307,6 +307,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { provider: ProviderID.make(info.providerID), providerMetadataKey: optionKey, protocol: "ai-sdk", + operation: "chat", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, transport: { diff --git a/packages/core/src/observability/agent.ts b/packages/core/src/observability/agent.ts index 2ab3a7f7d8..e496180ca8 100644 --- a/packages/core/src/observability/agent.ts +++ b/packages/core/src/observability/agent.ts @@ -1,7 +1,7 @@ export * as AgentTelemetry from "./agent" import type { LLMEvent, Usage } from "@opencode-ai/llm" -import { Cause, Clock, Context, Effect, Exit } from "effect" +import { Cause, Clock, Context, Effect, Exit, Option } from "effect" import { ParentSpan, type Span } from "effect/Tracer" import { ATTR_ERROR_TYPE, @@ -46,14 +46,15 @@ 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("@opencode/AgentTelemetry/Current", { - defaultValue: () => undefined, -}) - const FailureState = Context.Reference<{ stage?: string } | undefined>("@opencode/AgentTelemetry/FailureState", { defaultValue: () => undefined, }) +export const currentSpan = Effect.option(Effect.currentSpan).pipe( + Effect.map(Option.getOrUndefined), + Effect.map(findAgentSpan), +) + const observe = (effect: Effect.Effect) => Effect.catchCauseIf( effect, @@ -97,7 +98,6 @@ export const invoke = ( Effect.andThen( effect.pipe( Effect.withParentSpan(span, { captureStackTrace: false }), - Effect.provideService(Current, span), Effect.provideService(FailureState, failureState), Effect.withTracerEnabled(false), ), @@ -205,8 +205,8 @@ export const modelCall = (input: { }) const run = (effect: Effect.Effect) => Effect.gen(function* () { - const span = yield* Current - const parentSessionID = span?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID) + const agentSpan = yield* currentSpan + const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID) const observed = effect.pipe( Effect.annotateSpans({ [ATTR_GEN_AI_AGENT_NAME]: input.agent, @@ -219,8 +219,8 @@ export const modelCall = (input: { ...(typeof parentSessionID === "string" ? { [ATTR_OPENCODE_SESSION_PARENT_ID]: parentSessionID } : {}), }), ) - if (!span) return yield* observed - return yield* observed.pipe(Effect.withParentSpan(span, { captureStackTrace: false })) + if (!agentSpan) return yield* observed + return yield* observed.pipe(Effect.withParentSpan(agentSpan, { captureStackTrace: false })) }) return { observe: observeEvent, run } } @@ -271,7 +271,7 @@ function recordUsage(usage: Usage) { function event(name: string, attributes: Record) { return Effect.gen(function* () { - const span = yield* Current + const span = yield* currentSpan if (!span) return const time = yield* Clock.currentTimeNanos yield* observe(Effect.sync(() => span.event(name, time, attributes))) @@ -280,11 +280,18 @@ function event(name: string, attributes: Record) { function withCurrent(f: (span: Span) => void) { return Effect.gen(function* () { - const span = yield* Current + const span = yield* currentSpan if (span) yield* observe(Effect.sync(() => f(span))) }) } +function findAgentSpan(span: Span | undefined): Span | undefined { + if (!span) return + if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT) return span + const parent = Option.getOrUndefined(span.parent) + return findAgentSpan(parent?._tag === "Span" ? parent : undefined) +} + function classify(f: () => string) { return Effect.sync(f).pipe(Effect.catchCause(() => Effect.succeed("unknown"))) } diff --git a/packages/core/src/observability/http.ts b/packages/core/src/observability/http.ts index e7562b36de..f3830acfbd 100644 --- a/packages/core/src/observability/http.ts +++ b/packages/core/src/observability/http.ts @@ -39,8 +39,8 @@ export const use = ( Effect.flatMap(consume), ) const parent = - (yield* ToolTelemetry.Current) ?? - (yield* AgentTelemetry.Current) ?? + (yield* ToolTelemetry.currentSpan) ?? + (yield* AgentTelemetry.currentSpan) ?? Option.getOrUndefined(yield* Effect.option(Effect.currentSpan)) if (!parent) return yield* execute const url = URL.canParse(request.url) ? new URL(request.url) : undefined diff --git a/packages/core/src/observability/tool.ts b/packages/core/src/observability/tool.ts index 8da169c45d..258fa4f469 100644 --- a/packages/core/src/observability/tool.ts +++ b/packages/core/src/observability/tool.ts @@ -1,6 +1,6 @@ export * as ToolTelemetry from "./tool" -import { Cause, Clock, Context, Effect, Exit } from "effect" +import { Cause, Clock, Effect, Exit, Option } from "effect" import type { Span } from "effect/Tracer" import { ATTR_ERROR_TYPE, @@ -22,9 +22,10 @@ import { import { AgentTelemetry } from "./agent" import { SessionTelemetry } from "./session" -export const Current = Context.Reference("@opencode/ToolTelemetry/Current", { - defaultValue: () => undefined, -}) +export const currentSpan = Effect.option(Effect.currentSpan).pipe( + Effect.map(Option.getOrUndefined), + Effect.map(findToolSpan), +) const observe = (effect: Effect.Effect) => Effect.catchCauseIf( @@ -44,8 +45,8 @@ export const execute = ( resultErrorType?: (result: A) => string | undefined, ) => Effect.gen(function* () { - const toolSpan = yield* Current - const agentSpan = yield* AgentTelemetry.Current + const toolSpan = yield* currentSpan + const agentSpan = yield* AgentTelemetry.currentSpan const parent = toolSpan ?? agentSpan const parentSessionID = agentSpan?.attributes.get(ATTR_OPENCODE_SESSION_PARENT_ID) const span = yield* Effect.makeSpan(`execute_tool ${input.call.name}`, { @@ -104,14 +105,13 @@ export const execute = ( ) }), 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 + const span = yield* currentSpan if (span) yield* observe( Effect.sync(() => { @@ -130,3 +130,10 @@ export const child = (input: { readonly agent: string; readonly sessionID: strin ), } }) + +function findToolSpan(span: Span | undefined): Span | undefined { + if (!span) return + if (span.attributes.get(ATTR_GEN_AI_OPERATION_NAME) === GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL) return span + const parent = Option.getOrUndefined(span.parent) + return findToolSpan(parent?._tag === "Span" ? parent : undefined) +} diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index fddc345966..0a062f1ba1 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -178,6 +178,7 @@ const FakeProtocol = Protocol.make({ // Protocol ids are open strings, so external packages can define their own // protocols without changing this package. id: "fake-echo", + operation: "chat", body: { schema: FakeBody, from: (request) => diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 178b6919f6..171d2bb01d 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -862,6 +862,7 @@ const step = (state: ParserState, event: AnthropicEvent) => { */ export const protocol = Protocol.make({ id: ADAPTER, + operation: "chat", body: { schema: AnthropicMessagesBody, from: fromRequest, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 4984e32365..30289fc520 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -636,6 +636,7 @@ const onHalt = (state: ParserState): ReadonlyArray => */ export const protocol = Protocol.make({ id: ADAPTER, + operation: "chat", body: { schema: BedrockConverseBody, from: fromRequest, diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 82a69b059e..212c417829 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -485,6 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => { */ export const protocol = Protocol.make({ id: ADAPTER, + operation: "generate_content", body: { schema: GeminiBody, from: fromRequest, diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index cba67ce0a9..82cb522f11 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -476,6 +476,7 @@ const finishEvents = (state: ParserState): ReadonlyArray => { */ export const protocol = Protocol.make({ id: ADAPTER, + operation: "chat", body: { schema: OpenAIChatBody, from: fromRequest, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e2889d5b6d..73686a9b99 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -964,6 +964,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { */ export const protocol = Protocol.make({ id: ADAPTER, + operation: "chat", body: { schema: OpenAIResponsesBody, from: fromRequest, diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 914d7c0a0b..fa46014a78 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -37,6 +37,7 @@ export type OpenRouterBody = Schema.Schema.Type export const protocol = Protocol.make({ id: "openrouter-chat", + operation: OpenAIChat.protocol.operation, body: { schema: OpenRouterBody, from: (request) => diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 65a6798fd6..afc7b8b81d 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -6,7 +6,7 @@ import type { Framing } from "./framing" import { HttpTransport } from "./transport" import type { Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" -import type { Protocol } from "./protocol" +import type { GenAIOperation, Protocol } from "./protocol" import { applyCachePolicy } from "../cache-policy" import { LLMTelemetry } from "../telemetry" import { ProviderShared } from "../protocols/shared" @@ -39,6 +39,7 @@ export interface Route { /** ProviderMetadata namespace emitted and consumed by this route. */ readonly providerMetadataKey?: string readonly protocol: ProtocolID + readonly operation: GenAIOperation readonly endpoint: Endpoint readonly auth: Auth readonly transport: Transport @@ -256,6 +257,7 @@ function makeFromTransport( provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), providerMetadataKey: routeInput.providerMetadataKey, protocol: protocol.id, + operation: protocol.operation, endpoint: routeInput.endpoint, auth: routeInput.auth ?? Auth.none, transport: routeInput.transport, diff --git a/packages/llm/src/route/protocol.ts b/packages/llm/src/route/protocol.ts index acb1e78c67..453e6cef9d 100644 --- a/packages/llm/src/route/protocol.ts +++ b/packages/llm/src/route/protocol.ts @@ -1,6 +1,8 @@ import { Schema, type Effect } from "effect" import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" +export type GenAIOperation = "chat" | "generate_content" + /** * The semantic API contract of one model server family. * @@ -36,6 +38,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" export interface Protocol { /** Stable id for the wire protocol implementation. */ readonly id: ProtocolID + /** OpenTelemetry GenAI operation represented by one request. */ + readonly operation: GenAIOperation /** Request side: schema for the provider-native body and how to build it. */ readonly body: ProtocolBody /** Response side: streaming state machine. */ diff --git a/packages/llm/src/telemetry.ts b/packages/llm/src/telemetry.ts index cc99d44f92..87aea345c3 100644 --- a/packages/llm/src/telemetry.ts +++ b/packages/llm/src/telemetry.ts @@ -33,14 +33,11 @@ import { 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" -import { CurrentModelSpan } from "./telemetry/context" export { RequestIssued, ResponseChunkReceived } from "./telemetry/http" @@ -60,8 +57,7 @@ const observe = (effect: Effect.Effect) => export function stream(request: LLMRequest, source: Stream.Stream) { const generation = request.generation - const identity = telemetryIdentity(request) - const operation = identity.operation + const operation = request.model.route.operation const baseURL = request.model.route.endpoint.baseURL const url = baseURL && URL.canParse(baseURL) ? new URL(baseURL) : undefined const server = serverAttributes(url) @@ -73,7 +69,7 @@ export function stream(request: LLMRequest, source: Stream.Stream observe( Effect.sync(() => { @@ -309,21 +304,3 @@ function serverAttributes(url: URL | undefined) { ...(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 } -} diff --git a/packages/llm/src/telemetry/context.ts b/packages/llm/src/telemetry/context.ts index 8d0a2afbaf..a5add5d3cb 100644 --- a/packages/llm/src/telemetry/context.ts +++ b/packages/llm/src/telemetry/context.ts @@ -1,6 +1,15 @@ -import { Context } from "effect" +import { Effect, Option } from "effect" import type { Span } from "effect/Tracer" +import { ATTR_OPENCODE_LLM_ROUTE } from "../semconv" -export const CurrentModelSpan = Context.Reference("@opencode/LLM/Telemetry/CurrentModelSpan", { - defaultValue: () => undefined, -}) +export const currentModelSpan = Effect.option(Effect.currentSpan).pipe( + Effect.map(Option.getOrUndefined), + Effect.map(findModelSpan), +) + +function findModelSpan(span: Span | undefined): Span | undefined { + if (!span) return + if (span.attributes.has(ATTR_OPENCODE_LLM_ROUTE)) return span + const parent = Option.getOrUndefined(span.parent) + return findModelSpan(parent?._tag === "Span" ? parent : undefined) +} diff --git a/packages/llm/src/telemetry/http.ts b/packages/llm/src/telemetry/http.ts index 19cecca273..897be12e0c 100644 --- a/packages/llm/src/telemetry/http.ts +++ b/packages/llm/src/telemetry/http.ts @@ -16,7 +16,7 @@ import { ATTR_URL_SCHEME, } from "../semconv" import { LLMError } from "../schema" -import { CurrentModelSpan } from "./context" +import { currentModelSpan } from "./context" export const RequestIssued = Context.Reference<((time: bigint) => Effect.Effect) | undefined>( "@opencode/LLM/Telemetry/RequestIssued", @@ -46,7 +46,7 @@ export const stream = ( ) => Stream.unwrap( Effect.gen(function* () { - const parent = yield* CurrentModelSpan + const parent = yield* currentModelSpan if (!parent) return source(request) const url = URL.canParse(request.url) ? new URL(request.url) : undefined const port = url?.port diff --git a/packages/llm/src/telemetry/websocket.ts b/packages/llm/src/telemetry/websocket.ts index d324980db1..d01dcded5b 100644 --- a/packages/llm/src/telemetry/websocket.ts +++ b/packages/llm/src/telemetry/websocket.ts @@ -16,7 +16,7 @@ import { } from "../semconv" import { LLMError } from "../schema" import { safeUrl } from "./http" -import { CurrentModelSpan } from "./context" +import { currentModelSpan } from "./context" export { RequestIssued, ResponseChunkReceived } from "./http" @@ -30,7 +30,7 @@ const observe = (effect: Effect.Effect) => export const stream = (urlValue: string, source: Stream.Stream) => Stream.unwrap( Effect.gen(function* () { - const parent = yield* CurrentModelSpan + const parent = yield* currentModelSpan if (!parent) return source const url = URL.canParse(urlValue) ? new URL(urlValue) : undefined const port = url?.port diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index bbbb29f37a..1f5008f960 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -45,6 +45,7 @@ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => const fakeProtocol = Protocol.make({ id: "fake", + operation: "chat", body: { schema: Schema.Struct({ body: Schema.String, diff --git a/packages/llm/test/telemetry.test.ts b/packages/llm/test/telemetry.test.ts index eb7c4b5c58..798e95f4ed 100644 --- a/packages/llm/test/telemetry.test.ts +++ b/packages/llm/test/telemetry.test.ts @@ -3,6 +3,7 @@ import { Cause, Clock, Deferred, Effect, Fiber, References, Stream, Tracer } fro import * as TestClock from "effect/testing/TestClock" import { FetchHttpClient, HttpClientRequest } from "effect/unstable/http" import { LLM, LLMEvent, Message, Usage } from "../src" +import * as Gemini from "../src/protocols/gemini" import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIResponses from "../src/protocols/openai-responses" import { LLMClient } from "../src/route" @@ -444,7 +445,7 @@ describe("GenAI telemetry", () => { }), ) - it.effect("uses the semantic convention provider identity", () => + it.effect("uses the model provider and route operation identity", () => Effect.gen(function* () { const spans: Tracer.NativeSpan[] = [] const tracer = Tracer.make({ @@ -463,7 +464,18 @@ describe("GenAI telemetry", () => { 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") + const span = spans.find((span) => span.name === "chat grok") + expect(span?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("xai") + expect(span?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("chat") + + yield* instrument( + LLM.request({ model: Gemini.route.model({ id: "gemini-test" }), prompt: "secret" }), + Stream.fromIterable([LLMEvent.finish({ reason: "stop" })]), + ).pipe(Stream.runDrain, Effect.provideService(Tracer.Tracer, tracer)) + + const gemini = spans.find((span) => span.name === "generate_content gemini-test") + expect(gemini?.attributes.get(ATTR_GEN_AI_PROVIDER_NAME)).toBe("google") + expect(gemini?.attributes.get(ATTR_GEN_AI_OPERATION_NAME)).toBe("generate_content") }), )