From 564d2511f0250d77b47c7c67bb77cd4a1fca28eb Mon Sep 17 00:00:00 2001 From: moep90 Date: Mon, 24 Aug 2026 16:39:17 +0200 Subject: [PATCH] feat(traces): stamp opencode's session title on session spans Session spans carry the id only, and an id like ses_fcc85048effeCXe1RvEetcywD4 tells a reader nothing. opencode names every session from its first prompt and already ships that name in the session events, so the plugin can pass it on. session.created is emitted before the name exists, so the title arrives with session.updated a moment later. The handler records it and stamps it on the spans that are still open for that session, the subagent session span and the active run span, and handleRunStarted picks up a title that is already known for runs that start afterwards. Sessions opencode has not named carry no session.title at all, and an empty title never overwrites a known one. The map is bounded like the other per-session state and swept on session.idle. Signed-off-by: moep90 --- README.md | 15 ++++++ src/handlers/session.ts | 23 ++++++++- src/index.ts | 8 +++- src/types.ts | 1 + tests/handlers/session.test.ts | 85 ++++++++++++++++++++++++++++++++-- tests/helpers.ts | 1 + 6 files changed, 127 insertions(+), 6 deletions(-) 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(),