feat(llm): add GenAI operation telemetry metadata
This commit is contained in:
parent
f4e661f874
commit
2ba0817402
19 changed files with 85 additions and 58 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -862,6 +862,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
|||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: AnthropicMessagesBody,
|
||||
from: fromRequest,
|
||||
|
|
|
|||
|
|
@ -636,6 +636,7 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
|||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: BedrockConverseBody,
|
||||
from: fromRequest,
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "generate_content",
|
||||
body: {
|
||||
schema: GeminiBody,
|
||||
from: fromRequest,
|
||||
|
|
|
|||
|
|
@ -476,6 +476,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
|||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: OpenAIChatBody,
|
||||
from: fromRequest,
|
||||
|
|
|
|||
|
|
@ -964,6 +964,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
|||
*/
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
operation: "chat",
|
||||
body: {
|
||||
schema: OpenAIResponsesBody,
|
||||
from: fromRequest,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue