Skip to content

Commit f69d866

Browse files
committed
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 <volleyballlive@googlemail.com>
1 parent a051cda commit f69d866

6 files changed

Lines changed: 130 additions & 6 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
6464
| `tool_decision` | Permission prompt answered (accept/reject) |
6565
| `commit` | Git commit detected |
6666

67+
### Traces
68+
69+
| Span | Description |
70+
|------|-------------|
71+
| `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 |
72+
| `opencode.llm` | One model turn, with model, token counts and finish reason |
73+
| `opencode.tool.<name>` | One tool call, with its arguments in `input.value` and the result in `output.value` |
74+
75+
opencode names a session from its first prompt, shortly after the session
76+
starts. The plugin picks that name up from `session.updated` and stamps it on
77+
the spans that are still open, so a backend can show "Fix the flaky test"
78+
instead of `ses_fcc85048effeCXe1RvEetcywD4`. Sessions opencode has not named
79+
carry no `session.title` at all.
80+
6781
## Installation
6882

6983
Add the plugin to your opencode config at `~/.config/opencode/opencode.json`:

src/handlers/session.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import { SpanStatusCode } from "@opentelemetry/api"
3-
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
3+
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus, EventSessionUpdated } from "@opencode-ai/sdk"
44
import {
55
AGENT_NAME,
66
INPUT_MIME_TYPE,
@@ -24,6 +24,9 @@ import type { HandlerContext, SessionAgentType } from "../types.ts"
2424

2525
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
2626

27+
/** Attribute holding opencode's own name for the session, which it generates from the first prompt. */
28+
const SESSION_TITLE = "session.title"
29+
2730
/** Starts or refreshes the root run span for a single user turn, keyed by the user message ID. */
2831
export function handleRunStarted(
2932
runID: string,
@@ -38,10 +41,12 @@ export function handleRunStarted(
3841
ctx.pendingRuns.delete(sessionID)
3942
if (promptText) setBoundedMap(ctx.runInputs, runID, promptText)
4043
if (!isTraceEnabled("session", ctx)) return
44+
const title = ctx.sessionTitles.get(sessionID)
4145
const existing = ctx.runSpans.get(runID)
4246
if (existing) {
4347
existing.setAttributes({
4448
[AGENT_NAME]: agent,
49+
...(title ? { [SESSION_TITLE]: title } : {}),
4550
...(promptText
4651
? {
4752
[INPUT_VALUE]: promptText,
@@ -64,6 +69,7 @@ export function handleRunStarted(
6469
[AGENT_NAME]: agent,
6570
"agent.type": "primary",
6671
"session.is_subagent": false,
72+
...(title ? { [SESSION_TITLE]: title } : {}),
6773
...(promptText
6874
? {
6975
[INPUT_VALUE]: promptText,
@@ -81,9 +87,27 @@ export function handleRunStarted(
8187
setBoundedMap(ctx.runSpanContexts, runID, runSpan.spanContext())
8288
}
8389

90+
/**
91+
* Records opencode's session title and stamps it on the spans that are still open for that session.
92+
*
93+
* opencode emits `session.created` before it has named the session, then names it from the first
94+
* prompt and emits `session.updated`. Backends therefore see the id first and the title as soon as
95+
* it exists, on the same spans.
96+
*/
97+
export function handleSessionUpdated(e: EventSessionUpdated, ctx: HandlerContext) {
98+
const { id: sessionID, title } = e.properties.info
99+
if (!title || ctx.sessionTitles.get(sessionID) === title) return
100+
setBoundedMap(ctx.sessionTitles, sessionID, title)
101+
if (!isTraceEnabled("session", ctx)) return
102+
ctx.sessionSpans.get(sessionID)?.setAttribute(SESSION_TITLE, title)
103+
const runID = ctx.activeRuns.get(sessionID)
104+
if (runID) ctx.runSpans.get(runID)?.setAttribute(SESSION_TITLE, title)
105+
}
106+
84107
/** Increments the session counter, records start time, starts the root session span, and emits a `session.created` log event. */
85108
export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) {
86-
const { id: sessionID, time, parentID } = e.properties.info
109+
const { id: sessionID, time, parentID, title } = e.properties.info
110+
if (title) setBoundedMap(ctx.sessionTitles, sessionID, title)
87111
const createdAt = time.created
88112
const isSubagent = !!parentID
89113
const agentType: SessionAgentType = isSubagent ? "subagent" : "primary"
@@ -103,6 +127,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
103127
[AGENT_NAME]: "unknown",
104128
"agent.type": agentType,
105129
"session.is_subagent": isSubagent,
130+
...(title ? { [SESSION_TITLE]: title } : {}),
106131
...ctx.commonAttrs,
107132
},
108133
},
@@ -141,6 +166,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
141166
}
142167
}
143168
ctx.pendingRuns.delete(sessionID)
169+
ctx.sessionTitles.delete(sessionID)
144170
const msgPrefix = `${sessionID}:`
145171
for (const [key, span] of ctx.messageSpans) {
146172
if (key.startsWith(msgPrefix)) {

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions"
66
import pkg from "../package.json" with { type: "json" }
77
import type {
88
EventSessionCreated,
9+
EventSessionUpdated,
910
EventSessionIdle,
1011
EventSessionError,
1112
EventSessionStatus,
@@ -21,7 +22,7 @@ import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, ty
2122
import { probeEndpoint } from "./probe.ts"
2223
import { setupOtel, createInstruments, forceFlushOtel } from "./otel.ts"
2324
import { remoteParentContext } from "./trace-context.ts"
24-
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleRunStarted } from "./handlers/session.ts"
25+
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleSessionUpdated, handleRunStarted } from "./handlers/session.ts"
2526
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
2627
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
2728
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
@@ -112,6 +113,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
112113
const assistantRuns = new Map()
113114
const pendingRuns = new Map()
114115
const runInputs = new Map()
116+
const sessionTitles = new Map()
115117
const sessionSpans = new Map()
116118
const sessionSpanContexts = new Map()
117119
const messageSpans = new Map()
@@ -162,6 +164,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
162164
assistantRuns,
163165
pendingRuns,
164166
runInputs,
167+
sessionTitles,
165168
sessionSpans,
166169
sessionSpanContexts,
167170
messageSpans,
@@ -295,6 +298,9 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
295298

296299
event: safe("event", async ({ event }) => {
297300
switch (event.type) {
301+
case "session.updated":
302+
handleSessionUpdated(event as EventSessionUpdated, ctx)
303+
break
298304
case "session.created":
299305
await handleSessionCreated(event as EventSessionCreated, ctx)
300306
break

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export type HandlerContext = {
105105
assistantRuns: Map<string, string>
106106
pendingRuns: Map<string, PendingRun>
107107
runInputs: Map<string, string>
108+
sessionTitles: Map<string, string>
108109
sessionSpans: Map<string, Span>
109110
sessionSpanContexts: Map<string, SpanContext>
110111
messageSpans: Map<string, Span>

tests/handlers/session.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { describe, test, expect } from "bun:test"
2-
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "../../src/handlers/session.ts"
2+
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleSessionUpdated, handleRunStarted } from "../../src/handlers/session.ts"
33
import { makeCtx, makeTracer } from "../helpers.ts"
4-
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
4+
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus, EventSessionUpdated } from "@opencode-ai/sdk"
55
import type { Span } from "@opentelemetry/api"
66

7-
function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated {
7+
function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string, title?: string): EventSessionCreated {
88
return {
99
type: "session.created",
1010
properties: {
@@ -13,12 +13,28 @@ function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: stri
1313
projectID: "proj_test",
1414
directory: "/tmp",
1515
parentID,
16+
title,
1617
time: { created: createdAt },
1718
},
1819
},
1920
} as unknown as EventSessionCreated
2021
}
2122

23+
function makeSessionUpdated(sessionID: string, title: string): EventSessionUpdated {
24+
return {
25+
type: "session.updated",
26+
properties: {
27+
info: {
28+
id: sessionID,
29+
projectID: "proj_test",
30+
directory: "/tmp",
31+
title,
32+
time: { created: 1000, updated: 2000 },
33+
},
34+
},
35+
} as unknown as EventSessionUpdated
36+
}
37+
2238
function makeSessionIdle(sessionID: string): EventSessionIdle {
2339
return { type: "session.idle", properties: { sessionID } } as EventSessionIdle
2440
}
@@ -261,3 +277,63 @@ describe("handleSessionStatus", () => {
261277
expect(counters.retry.calls).toHaveLength(0)
262278
})
263279
})
280+
281+
describe("session title", () => {
282+
test("remembers the title opencode already has at creation", async () => {
283+
const { ctx } = makeCtx()
284+
await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx)
285+
expect(ctx.sessionTitles.get("ses_1")).toBe("Fix the flaky test")
286+
})
287+
288+
test("puts the title on a subagent session span", async () => {
289+
const { ctx, tracer } = makeCtx()
290+
await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent", "Fix the flaky test"), ctx)
291+
expect(tracer.spans.at(0)!.attributes["session.title"]).toBe("Fix the flaky test")
292+
})
293+
294+
test("stamps a later title on the open session span", async () => {
295+
const { ctx, tracer } = makeCtx()
296+
await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx)
297+
handleSessionUpdated(makeSessionUpdated("ses_child", "Fix the flaky test"), ctx)
298+
expect(tracer.spans.at(0)!.attributes["session.title"]).toBe("Fix the flaky test")
299+
})
300+
301+
test("stamps a later title on the open run span", async () => {
302+
const { ctx, tracer } = makeCtx()
303+
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
304+
handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx)
305+
handleSessionUpdated(makeSessionUpdated("ses_1", "Fix the flaky test"), ctx)
306+
const runSpan = tracer.spans.find((s) => s.attributes["session.id"] === "ses_1")!
307+
expect(runSpan.attributes["session.title"]).toBe("Fix the flaky test")
308+
})
309+
310+
test("puts a known title on a run span started afterwards", async () => {
311+
const { ctx, tracer } = makeCtx()
312+
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
313+
handleSessionUpdated(makeSessionUpdated("ses_1", "Fix the flaky test"), ctx)
314+
handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx)
315+
expect(tracer.spans.at(-1)!.attributes["session.title"]).toBe("Fix the flaky test")
316+
})
317+
318+
test("ignores an empty title and keeps the one it has", async () => {
319+
const { ctx } = makeCtx()
320+
await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx)
321+
handleSessionUpdated(makeSessionUpdated("ses_1", ""), ctx)
322+
expect(ctx.sessionTitles.get("ses_1")).toBe("Fix the flaky test")
323+
})
324+
325+
test("forgets the title when the session goes idle", async () => {
326+
const { ctx } = makeCtx()
327+
await handleSessionCreated(makeSessionCreated("ses_1", 1000, undefined, "Fix the flaky test"), ctx)
328+
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
329+
expect(ctx.sessionTitles.has("ses_1")).toBe(false)
330+
})
331+
332+
test("skips span work when session traces are disabled", async () => {
333+
const { ctx, tracer } = makeCtx("proj_test", [], ["session"])
334+
await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx)
335+
handleSessionUpdated(makeSessionUpdated("ses_child", "Fix the flaky test"), ctx)
336+
expect(tracer.spans).toHaveLength(0)
337+
expect(ctx.sessionTitles.get("ses_child")).toBe("Fix the flaky test")
338+
})
339+
})

tests/helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export function makeCtx(
238238
assistantRuns: new Map(),
239239
pendingRuns: new Map(),
240240
runInputs: new Map(),
241+
sessionTitles: new Map(),
241242
sessionSpans: new Map(),
242243
sessionSpanContexts: new Map(),
243244
messageSpans: new Map(),

0 commit comments

Comments
 (0)