diff --git a/README.md b/README.md index 36ae0a7..d99284f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [What it instruments](#what-it-instruments) - [Metrics](#metrics) - [Log events](#log-events) + - [Traces](#traces) - [Installation](#installation) - [Configuration](#configuration) - [Plugin options (opencode.json)](#plugin-options-opencodejson) @@ -64,6 +65,20 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet | `tool_decision` | Permission prompt answered (accept/reject) | | `commit` | Git commit detected | +### Traces + +| Span | Description | +|------|-------------| +| `opencode.session` | One user turn, or a subagent session. Carries `session.id`, the prompt in `input.value`, and `session.title` once opencode has named the session | +| `opencode.llm` | One model turn, with model, token counts and finish reason | +| `opencode.tool.` | One tool call, with its arguments in `input.value` and the result in `output.value` | + +opencode names a session from its first prompt, shortly after the session +starts. The plugin picks that name up from `session.updated` and stamps it on +the spans that are still open, so a backend can show "Fix the flaky test" +instead of `ses_fcc85048effeCXe1RvEetcywD4`. Sessions opencode has not named +carry no `session.title` at all. + ## Installation Add the plugin to your opencode config at `~/.config/opencode/opencode.json`: diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9a952dc..9d08363 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -1,6 +1,6 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import { SpanStatusCode } from "@opentelemetry/api" -import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" +import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus, EventSessionUpdated } from "@opencode-ai/sdk" import { AGENT_NAME, INPUT_MIME_TYPE, @@ -24,6 +24,8 @@ import type { HandlerContext, SessionAgentType } from "../types.ts" const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND +const SESSION_TITLE = "session.title" + /** Starts or refreshes the root run span for a single user turn, keyed by the user message ID. */ export function handleRunStarted( runID: string, @@ -38,10 +40,12 @@ export function handleRunStarted( ctx.pendingRuns.delete(sessionID) if (promptText) setBoundedMap(ctx.runInputs, runID, promptText) if (!isTraceEnabled("session", ctx)) return + const title = ctx.sessionTitles.get(sessionID) const existing = ctx.runSpans.get(runID) if (existing) { existing.setAttributes({ [AGENT_NAME]: agent, + ...(title ? { [SESSION_TITLE]: title } : {}), ...(promptText ? { [INPUT_VALUE]: promptText, @@ -64,6 +68,7 @@ export function handleRunStarted( [AGENT_NAME]: agent, "agent.type": "primary", "session.is_subagent": false, + ...(title ? { [SESSION_TITLE]: title } : {}), ...(promptText ? { [INPUT_VALUE]: promptText, @@ -81,9 +86,21 @@ export function handleRunStarted( setBoundedMap(ctx.runSpanContexts, runID, runSpan.spanContext()) } +/** Records opencode's session title, which arrives after `session.created`, and stamps it on the session and run spans still open. */ +export function handleSessionUpdated(e: EventSessionUpdated, ctx: HandlerContext) { + const { id: sessionID, title } = e.properties.info + if (!title || ctx.sessionTitles.get(sessionID) === title) return + setBoundedMap(ctx.sessionTitles, sessionID, title) + if (!isTraceEnabled("session", ctx)) return + ctx.sessionSpans.get(sessionID)?.setAttribute(SESSION_TITLE, title) + const runID = ctx.activeRuns.get(sessionID) + if (runID) ctx.runSpans.get(runID)?.setAttribute(SESSION_TITLE, title) +} + /** Increments the session counter, records start time, starts the root session span, and emits a `session.created` log event. */ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) { - const { id: sessionID, time, parentID } = e.properties.info + const { id: sessionID, time, parentID, title } = e.properties.info + if (title) setBoundedMap(ctx.sessionTitles, sessionID, title) const createdAt = time.created const isSubagent = !!parentID const agentType: SessionAgentType = isSubagent ? "subagent" : "primary" @@ -103,6 +120,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext [AGENT_NAME]: "unknown", "agent.type": agentType, "session.is_subagent": isSubagent, + ...(title ? { [SESSION_TITLE]: title } : {}), ...ctx.commonAttrs, }, }, @@ -141,6 +159,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) { } } ctx.pendingRuns.delete(sessionID) + ctx.sessionTitles.delete(sessionID) const msgPrefix = `${sessionID}:` for (const [key, span] of ctx.messageSpans) { if (key.startsWith(msgPrefix)) { diff --git a/src/index.ts b/src/index.ts index 194270d..7a5d471 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions" import pkg from "../package.json" with { type: "json" } import type { EventSessionCreated, + EventSessionUpdated, EventSessionIdle, EventSessionError, EventSessionStatus, @@ -21,7 +22,7 @@ import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, ty import { probeEndpoint } from "./probe.ts" import { setupOtel, createInstruments, forceFlushOtel } from "./otel.ts" import { remoteParentContext } from "./trace-context.ts" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleRunStarted } from "./handlers/session.ts" +import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleSessionUpdated, handleRunStarted } from "./handlers/session.ts" import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts" import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts" import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts" @@ -112,6 +113,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree const assistantRuns = new Map() const pendingRuns = new Map() const runInputs = new Map() + const sessionTitles = new Map() const sessionSpans = new Map() const sessionSpanContexts = new Map() const messageSpans = new Map() @@ -162,6 +164,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree assistantRuns, pendingRuns, runInputs, + sessionTitles, sessionSpans, sessionSpanContexts, messageSpans, @@ -295,6 +298,9 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree event: safe("event", async ({ event }) => { switch (event.type) { + case "session.updated": + handleSessionUpdated(event as EventSessionUpdated, ctx) + break case "session.created": await handleSessionCreated(event as EventSessionCreated, ctx) break diff --git a/src/types.ts b/src/types.ts index da0e816..b485f09 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,7 @@ export type HandlerContext = { assistantRuns: Map pendingRuns: Map runInputs: Map + sessionTitles: Map sessionSpans: Map sessionSpanContexts: Map messageSpans: Map diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index bbada8a..27b99b0 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -1,10 +1,10 @@ import { describe, test, expect } from "bun:test" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "../../src/handlers/session.ts" +import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleSessionUpdated, handleRunStarted } from "../../src/handlers/session.ts" import { makeCtx, makeTracer } from "../helpers.ts" -import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" +import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus, EventSessionUpdated } from "@opencode-ai/sdk" import type { Span } from "@opentelemetry/api" -function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated { +function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string, title?: string): EventSessionCreated { return { type: "session.created", properties: { @@ -13,12 +13,28 @@ function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: stri projectID: "proj_test", directory: "/tmp", parentID, + title, time: { created: createdAt }, }, }, } as unknown as EventSessionCreated } +function makeSessionUpdated(sessionID: string, title: string): EventSessionUpdated { + return { + type: "session.updated", + properties: { + info: { + id: sessionID, + projectID: "proj_test", + directory: "/tmp", + title, + time: { created: 1000, updated: 2000 }, + }, + }, + } as unknown as EventSessionUpdated +} + function makeSessionIdle(sessionID: string): EventSessionIdle { return { type: "session.idle", properties: { sessionID } } as EventSessionIdle } @@ -261,3 +277,66 @@ describe("handleSessionStatus", () => { expect(counters.retry.calls).toHaveLength(0) }) }) + +describe("handleSessionCreated — session title", () => { + test("remembers the title opencode already has at creation", async () => { + const { ctx } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx) + expect(ctx.sessionTitles.get("ses_1")).toBe("Fix the flaky test") + }) + + test("puts the title on a subagent session span", async () => { + const { ctx, tracer } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent", "Fix the flaky test"), ctx) + expect(tracer.spans.at(0)!.attributes["session.title"]).toBe("Fix the flaky test") + }) + +}) + +describe("handleSessionUpdated", () => { + test("stamps a later title on the open session span", async () => { + const { ctx, tracer } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) + handleSessionUpdated(makeSessionUpdated("ses_child", "Fix the flaky test"), ctx) + expect(tracer.spans.at(0)!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("stamps a later title on the open run span", async () => { + const { ctx, tracer } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) + handleSessionUpdated(makeSessionUpdated("ses_1", "Fix the flaky test"), ctx) + const runSpan = tracer.spans.find((s) => s.attributes["session.id"] === "ses_1")! + expect(runSpan.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("puts a known title on a run span started afterwards", async () => { + const { ctx, tracer } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleSessionUpdated(makeSessionUpdated("ses_1", "Fix the flaky test"), ctx) + handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) + expect(tracer.spans.at(-1)!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("ignores an empty title and keeps the one it has", async () => { + const { ctx } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx) + handleSessionUpdated(makeSessionUpdated("ses_1", ""), ctx) + expect(ctx.sessionTitles.get("ses_1")).toBe("Fix the flaky test") + }) + + test("forgets the title when the session goes idle", async () => { + const { ctx } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + expect(ctx.sessionTitles.has("ses_1")).toBe(false) + }) + + test("skips span work when session traces are disabled", async () => { + const { ctx, tracer } = makeCtx("proj_test", [], ["session"]) + await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) + handleSessionUpdated(makeSessionUpdated("ses_child", "Fix the flaky test"), ctx) + expect(tracer.spans).toHaveLength(0) + expect(ctx.sessionTitles.get("ses_child")).toBe("Fix the flaky test") + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index a1594b9..6cea036 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -238,6 +238,7 @@ export function makeCtx( assistantRuns: new Map(), pendingRuns: new Map(), runInputs: new Map(), + sessionTitles: new Map(), sessionSpans: new Map(), sessionSpanContexts: new Map(), messageSpans: new Map(),