Skip to content

Commit 447b4e5

Browse files
Merge pull request #32 from DEVtheOPS/feat/openinference-semantics
2 parents 92c9583 + ce6ca28 commit 447b4e5

8 files changed

Lines changed: 176 additions & 34 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"url": "https://github.com/DEVtheOPS/opencode-plugin-otel/issues"
55
},
66
"dependencies": {
7+
"@arizeai/openinference-semantic-conventions": "^2.2.0",
78
"@opencode-ai/plugin": "^1.2.23",
89
"@opencode-ai/sdk": "^1.2.23",
910
"@opentelemetry/api": "^1.9.0",

src/handlers/message.ts

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import { SpanStatusCode, SpanKind, context, trace } from "@opentelemetry/api"
33
import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, ToolPart } from "@opencode-ai/sdk"
4+
import {
5+
AGENT_NAME,
6+
INPUT_MIME_TYPE,
7+
INPUT_VALUE,
8+
LLM_COST_TOTAL,
9+
LLM_INPUT_MESSAGES,
10+
LLM_MODEL_NAME,
11+
LLM_OUTPUT_MESSAGES,
12+
LLM_PROVIDER,
13+
LLM_SYSTEM,
14+
LLM_TOKEN_COUNT_COMPLETION,
15+
LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING,
16+
LLM_TOKEN_COUNT_PROMPT,
17+
LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ,
18+
LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE,
19+
LLM_TOKEN_COUNT_TOTAL,
20+
MimeType,
21+
OpenInferenceSpanKind,
22+
OUTPUT_MIME_TYPE,
23+
OUTPUT_VALUE,
24+
SemanticConventions,
25+
SESSION_ID,
26+
TOOL_ID,
27+
TOOL_NAME,
28+
TOOL_PARAMETERS,
29+
} from "@arizeai/openinference-semantic-conventions"
430
import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled, isTraceEnabled } from "../util.ts"
531
import type { HandlerContext } from "../types.ts"
632

33+
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
34+
const LLM_FINISH_REASON = "llm.finish_reason"
35+
736
type SubtaskPart = {
837
type: "subtask"
938
sessionID: string
@@ -79,13 +108,23 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
79108
const msgKey = `${sessionID}:${assistant.id}`
80109
const msgSpan = ctx.messageSpans.get(msgKey)
81110
if (msgSpan) {
111+
const outputText = ctx.messageOutputs.get(msgKey)
82112
msgSpan.setAttributes({
83-
"gen_ai.usage.input_tokens": assistant.tokens.input,
84-
"gen_ai.usage.output_tokens": assistant.tokens.output,
85-
"gen_ai.usage.reasoning_tokens": assistant.tokens.reasoning,
86-
"gen_ai.usage.cache_read_tokens": assistant.tokens.cache.read,
87-
"gen_ai.usage.cache_creation_tokens": assistant.tokens.cache.write,
88-
"gen_ai.response.finish_reason": assistant.error ? "error" : "stop",
113+
[LLM_TOKEN_COUNT_PROMPT]: assistant.tokens.input,
114+
[LLM_TOKEN_COUNT_COMPLETION]: assistant.tokens.output,
115+
[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: assistant.tokens.reasoning,
116+
[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: assistant.tokens.cache.read,
117+
[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: assistant.tokens.cache.write,
118+
[LLM_TOKEN_COUNT_TOTAL]: totalTokens,
119+
[LLM_FINISH_REASON]: assistant.error ? "error" : (assistant.finish ?? "stop"),
120+
[LLM_COST_TOTAL]: assistant.cost,
121+
...(outputText
122+
? {
123+
[OUTPUT_VALUE]: outputText,
124+
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
125+
[LLM_OUTPUT_MESSAGES]: JSON.stringify([{ role: "assistant", content: outputText }]),
126+
}
127+
: {}),
89128
cost_usd: assistant.cost,
90129
duration_ms: duration,
91130
})
@@ -96,6 +135,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
96135
}
97136
msgSpan.end(assistant.time.completed)
98137
ctx.messageSpans.delete(msgKey)
138+
ctx.messageOutputs.delete(msgKey)
99139
}
100140

101141
if (assistant.error) {
@@ -171,6 +211,12 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
171211
export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: HandlerContext) {
172212
const part = e.properties.part
173213

214+
if (part.type === "text") {
215+
const key = `${part.sessionID}:${part.messageID}`
216+
ctx.messageOutputs.set(key, `${ctx.messageOutputs.get(key) ?? ""}${part.text}`)
217+
return
218+
}
219+
174220
if (part.type === "subtask") {
175221
const subtask = part as unknown as SubtaskPart
176222
if (isMetricEnabled("subtask.count", ctx)) {
@@ -219,8 +265,13 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
219265
startTime: toolPart.state.time.start,
220266
kind: SpanKind.INTERNAL,
221267
attributes: {
222-
"session.id": toolPart.sessionID,
223-
"tool.name": toolPart.tool,
268+
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL,
269+
[SESSION_ID]: toolPart.sessionID,
270+
[TOOL_ID]: toolPart.callID,
271+
[TOOL_NAME]: toolPart.tool,
272+
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
273+
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
274+
[INPUT_MIME_TYPE]: MimeType.JSON,
224275
...ctx.commonAttrs,
225276
},
226277
},
@@ -269,8 +320,13 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
269320
startTime: start,
270321
kind: SpanKind.INTERNAL,
271322
attributes: {
272-
"session.id": toolPart.sessionID,
273-
"tool.name": toolPart.tool,
323+
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL,
324+
[SESSION_ID]: toolPart.sessionID,
325+
[TOOL_ID]: toolPart.callID,
326+
[TOOL_NAME]: toolPart.tool,
327+
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
328+
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
329+
[INPUT_MIME_TYPE]: MimeType.JSON,
274330
...ctx.commonAttrs,
275331
},
276332
},
@@ -280,10 +336,18 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
280336
toolSpan.setAttribute("tool.success", success)
281337
if (success) {
282338
const output = (toolPart.state as { output: string }).output
339+
toolSpan.setAttributes({
340+
[OUTPUT_VALUE]: output,
341+
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
342+
})
283343
toolSpan.setAttribute("tool.result_size_bytes", Buffer.byteLength(output, "utf8"))
284344
toolSpan.setStatus({ code: SpanStatusCode.OK })
285345
} else {
286346
const err = (toolPart.state as { error: string }).error
347+
toolSpan.setAttributes({
348+
[OUTPUT_VALUE]: err,
349+
[OUTPUT_MIME_TYPE]: MimeType.TEXT,
350+
})
287351
toolSpan.setAttribute("tool.error", err)
288352
toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: err })
289353
}
@@ -349,14 +413,24 @@ export function startMessageSpan(
349413
: context.active()
350414

351415
const msgSpan = ctx.tracer.startSpan(
352-
"gen_ai.chat",
416+
`${ctx.tracePrefix}llm`,
353417
{
354418
startTime,
355419
kind: SpanKind.CLIENT,
356420
attributes: {
357-
"gen_ai.system": providerID,
358-
"gen_ai.request.model": modelID,
359-
"session.id": sessionID,
421+
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM,
422+
[SESSION_ID]: sessionID,
423+
[AGENT_NAME]: ctx.sessionTotals.get(sessionID)?.agent ?? "unknown",
424+
[LLM_SYSTEM]: providerID,
425+
[LLM_PROVIDER]: providerID,
426+
[LLM_MODEL_NAME]: modelID,
427+
...(ctx.sessionInputs.has(sessionID)
428+
? {
429+
[INPUT_VALUE]: ctx.sessionInputs.get(sessionID)!,
430+
[INPUT_MIME_TYPE]: MimeType.TEXT,
431+
[LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: ctx.sessionInputs.get(sessionID)! }]),
432+
}
433+
: {}),
360434
...ctx.commonAttrs,
361435
},
362436
},

src/handlers/session.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import { SpanStatusCode, context, trace } from "@opentelemetry/api"
33
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
4+
import { AGENT_NAME, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions"
45
import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts"
56
import type { HandlerContext } from "../types.ts"
67

8+
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
9+
710
/** Increments the session counter, records start time, starts the root session span, and emits a `session.created` log event. */
811
export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) {
912
const { id: sessionID, time, parentID } = e.properties.info
@@ -29,7 +32,9 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
2932
{
3033
startTime: createdAt,
3134
attributes: {
32-
"session.id": sessionID,
35+
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT,
36+
[SESSION_ID]: sessionID,
37+
[AGENT_NAME]: "unknown",
3338
"session.is_subagent": isSubagent,
3439
...ctx.commonAttrs,
3540
},
@@ -61,6 +66,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
6166
ctx.pendingToolSpans.delete(key)
6267
}
6368
}
69+
ctx.sessionInputs.delete(sessionID)
6470
const msgPrefix = `${sessionID}:`
6571
for (const [key, span] of ctx.messageSpans) {
6672
if (key.startsWith(msgPrefix)) {
@@ -69,6 +75,9 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
6975
ctx.messageSpans.delete(key)
7076
}
7177
}
78+
for (const key of ctx.messageOutputs.keys()) {
79+
if (key.startsWith(msgPrefix)) ctx.messageOutputs.delete(key)
80+
}
7281
}
7382

7483
/** Emits a `session.idle` log event, records duration and session total histograms, ends the session span, and clears pending state. */
@@ -98,6 +107,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
98107
if (sessionSpan) {
99108
if (totals) {
100109
sessionSpan.setAttributes({
110+
[AGENT_NAME]: totals.agent,
101111
"session.total_tokens": totals.tokens,
102112
"session.total_cost_usd": totals.cost,
103113
"session.total_messages": totals.messages,
@@ -140,6 +150,8 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
140150
if (rawID) {
141151
const sessionSpan = ctx.sessionSpans.get(rawID)
142152
if (sessionSpan) {
153+
const totals = ctx.sessionTotals.get(rawID)
154+
if (totals) sessionSpan.setAttribute(AGENT_NAME, totals.agent)
143155
sessionSpan.setStatus({ code: SpanStatusCode.ERROR, message: error })
144156
sessionSpan.setAttribute("error", error)
145157
sessionSpan.end()

src/index.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Plugin } from "@opencode-ai/plugin"
22
import { SeverityNumber } from "@opentelemetry/api-logs"
33
import { logs } from "@opentelemetry/api-logs"
44
import { trace } from "@opentelemetry/api"
5+
import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions"
56
import pkg from "../package.json" with { type: "json" }
67
import type {
78
EventSessionCreated,
@@ -86,6 +87,8 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
8687
const sessionTotals = new Map()
8788
const sessionSpans = new Map()
8889
const messageSpans = new Map()
90+
const sessionInputs = new Map()
91+
const messageOutputs = new Map()
8992
const { disabledMetrics, disabledTraces } = config
9093
const commonAttrs = { "project.id": project.id } as const
9194

@@ -111,6 +114,8 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
111114
tracePrefix: config.metricPrefix,
112115
sessionSpans,
113116
messageSpans,
117+
sessionInputs,
118+
messageOutputs,
114119
}
115120

116121
async function shutdown() {
@@ -153,10 +158,24 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
153158
const agent = input.agent ?? "unknown"
154159
const totals = sessionTotals.get(input.sessionID)
155160
if (totals) totals.agent = agent
156-
const promptLength = output.parts.reduce(
157-
(acc, p) => (p.type === "text" ? acc + p.text.length : acc),
158-
0,
159-
)
161+
const sessionSpan = sessionSpans.get(input.sessionID)
162+
if (sessionSpan) sessionSpan.setAttribute(AGENT_NAME, agent)
163+
const promptText = output.parts.map((part) => {
164+
switch (part.type) {
165+
case "text":
166+
return part.text
167+
case "file":
168+
return part.filename ?? part.url
169+
case "agent":
170+
return part.name
171+
case "subtask":
172+
return part.description
173+
default:
174+
return ""
175+
}
176+
}).filter(Boolean).join("\n")
177+
sessionInputs.set(input.sessionID, promptText)
178+
const promptLength = promptText.length
160179
logger.emit({
161180
severityNumber: SeverityNumber.INFO,
162181
severityText: "INFO",

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,6 @@ export type HandlerContext = {
7777
tracePrefix: string
7878
sessionSpans: Map<string, Span>
7979
messageSpans: Map<string, Span>
80+
sessionInputs: Map<string, string>
81+
messageOutputs: Map<string, string>
8082
}

0 commit comments

Comments
 (0)