feat(llm): add GenAI operation telemetry metadata

This commit is contained in:
starptech 2026-07-09 17:03:52 +01:00
commit 2ba0817402
19 changed files with 85 additions and 58 deletions

View file

@ -307,6 +307,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
operation: "chat",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
auth: Auth.none,
transport: {

View file

@ -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<Span | undefined>("@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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
effect,
@ -97,7 +98,6 @@ export const invoke = <A, E, R>(
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 = <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 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<string, unknown>) {
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<string, unknown>) {
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")))
}

View file

@ -39,8 +39,8 @@ export const use = <A, E, R>(
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

View file

@ -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<Span | undefined>("@opencode/ToolTelemetry/Current", {
defaultValue: () => undefined,
})
export const currentSpan = Effect.option(Effect.currentSpan).pipe(
Effect.map(Option.getOrUndefined),
Effect.map(findToolSpan),
)
const observe = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.catchCauseIf(
@ -44,8 +45,8 @@ export const execute = <A, E, R>(
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 = <A, E, R>(
)
}),
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)
}

View file

@ -178,6 +178,7 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
// Protocol ids are open strings, so external packages can define their own
// protocols without changing this package.
id: "fake-echo",
operation: "chat",
body: {
schema: FakeBody,
from: (request) =>

View file

@ -862,6 +862,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: AnthropicMessagesBody,
from: fromRequest,

View file

@ -636,6 +636,7 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: BedrockConverseBody,
from: fromRequest,

View file

@ -485,6 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "generate_content",
body: {
schema: GeminiBody,
from: fromRequest,

View file

@ -476,6 +476,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: OpenAIChatBody,
from: fromRequest,

View file

@ -964,6 +964,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
*/
export const protocol = Protocol.make({
id: ADAPTER,
operation: "chat",
body: {
schema: OpenAIResponsesBody,
from: fromRequest,

View file

@ -37,6 +37,7 @@ export type OpenRouterBody = Schema.Schema.Type<typeof OpenRouterBody>
export const protocol = Protocol.make({
id: "openrouter-chat",
operation: OpenAIChat.protocol.operation,
body: {
schema: OpenRouterBody,
from: (request) =>

View file

@ -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<Body, Prepared = unknown> {
/** ProviderMetadata namespace emitted and consumed by this route. */
readonly providerMetadataKey?: string
readonly protocol: ProtocolID
readonly operation: GenAIOperation
readonly endpoint: Endpoint<Body>
readonly auth: Auth
readonly transport: Transport<Body, Prepared, unknown>
@ -256,6 +257,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
providerMetadataKey: routeInput.providerMetadataKey,
protocol: protocol.id,
operation: protocol.operation,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
transport: routeInput.transport,

View file

@ -1,6 +1,8 @@
import { Schema, type Effect } from "effect"
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
export type GenAIOperation = "chat" | "generate_content"
/**
* The semantic API contract of one model server family.
*
@ -36,6 +38,8 @@ import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
export interface Protocol<Body, Frame, Event, State> {
/** Stable id for the wire protocol implementation. */
readonly id: ProtocolID
/** OpenTelemetry GenAI operation represented by one request. */
readonly operation: GenAIOperation
/** Request side: schema for the provider-native body and how to build it. */
readonly body: ProtocolBody<Body>
/** Response side: streaming state machine. */

View file

@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
export function stream(request: LLMRequest, source: Stream.Stream<LLMEvent, LLMError>) {
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<LLMEvent, LLME
: undefined
const attributes = {
[ATTR_GEN_AI_OPERATION_NAME]: operation,
[ATTR_GEN_AI_PROVIDER_NAME]: identity.provider,
[ATTR_GEN_AI_PROVIDER_NAME]: request.model.provider,
[ATTR_GEN_AI_REQUEST_MODEL]: request.model.id,
[ATTR_GEN_AI_REQUEST_STREAM]: true,
[ATTR_OPENCODE_LLM_ROUTE]: request.model.route.id,
@ -177,7 +173,6 @@ function observeStream(input: {
),
),
Stream.provideService(ParentSpan, input.span),
Stream.provideService(CurrentModelSpan, input.span),
Stream.provideService(RequestIssued, (time) =>
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 }
}

View file

@ -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<Span | undefined>("@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)
}

View file

@ -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<void>) | undefined>(
"@opencode/LLM/Telemetry/RequestIssued",
@ -46,7 +46,7 @@ export const stream = <A, R>(
) =>
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

View file

@ -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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
export const stream = <A, R>(urlValue: string, source: Stream.Stream<A, LLMError, R>) =>
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

View file

@ -45,6 +45,7 @@ const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
id: "fake",
operation: "chat",
body: {
schema: Schema.Struct({
body: Schema.String,

View file

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