diff --git a/README.md b/README.md index 5b87b73..3ed9c22 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,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) @@ -98,6 +99,20 @@ the parent run span; a background child may outlive its already-ended dispatch t | `commit` | Git commit detected | | `subtask_invoked` | Sub-agent session created (agent and parent session ID) | +### 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.renamed` 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 3d9c65a..57cdd6c 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -25,6 +25,8 @@ import { const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND +const SESSION_TITLE = "session.title" + export function handlePromptEnqueued(e: EventOf<"session.inbox.enqueued">, ctx: HandlerContext, capturePrompt: boolean) { const { sessionID, inboxID, item } = e.data if (item.type !== "user") return @@ -106,6 +108,7 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler const isSubagent = !!d.parentID const agentType: SessionAgentType = isSubagent ? "subagent" : "primary" const agent = d.agent ?? "unknown" + if (d.title) setBoundedMap(ctx.tracing.sessionTitles, d.sessionID, d.title) setBoundedMap(ctx.tracing.sessionProjects, d.sessionID, d.projectID) setBoundedMap(ctx.tracing.sessionIdentity, d.sessionID, { @@ -167,6 +170,14 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler void ctx.log("info", "otel: session.created", { sessionID: d.sessionID, isSubagent }) } +/** Records opencode's session title, which arrives after `session.created`, and stamps it on the open run span. */ +export function handleSessionRenamed(e: EventOf<"session.renamed">, ctx: HandlerContext) { + const { sessionID, title } = e.data + if (!title || ctx.tracing.sessionTitles.get(sessionID) === title) return + setBoundedMap(ctx.tracing.sessionTitles, sessionID, title) + ctx.tracing.runSpans.get(sessionID)?.setAttribute(SESSION_TITLE, title) +} + export function handleAgentSelected(e: EventOf<"session.agent.selected">, ctx: HandlerContext) { const { sessionID, agent } = e.data const identity = ctx.tracing.sessionIdentity.get(sessionID) @@ -192,6 +203,7 @@ export function handleExecutionStarted(e: EventOf<"session.execution.started">, ? resolveSubagentTraceContext(sessionID, totals.parentID, totals.agent, ctx) : ctx.rootContext() const promptText = pendingPrompt?.text ?? "" + const title = ctx.tracing.sessionTitles.get(sessionID) const span = ctx.tracer.startSpan( `${ctx.tracePrefix}session`, @@ -203,6 +215,7 @@ export function handleExecutionStarted(e: EventOf<"session.execution.started">, [AGENT_NAME]: totals?.agent ?? "unknown", "agent.type": totals?.agentType ?? "primary", "session.is_subagent": isSubagent, + ...(title ? { [SESSION_TITLE]: title } : {}), ...(promptText ? { [INPUT_VALUE]: promptText, diff --git a/src/plugin.ts b/src/plugin.ts index a416d61..e76e797 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -15,6 +15,7 @@ import { handlePromptEnqueued, handleSessionCreated, handleSessionIdle, + handleSessionRenamed, handleRetryScheduled, handleSessionStatus, handleUsageUpdated, @@ -161,6 +162,9 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> case "session.created": handleSessionCreated(event, eventCtx) break + case "session.renamed": + handleSessionRenamed(event, eventCtx) + break case "session.agent.selected": handleAgentSelected(event, eventCtx) break diff --git a/src/state.ts b/src/state.ts index e3395c0..9f7c1d9 100644 --- a/src/state.ts +++ b/src/state.ts @@ -125,6 +125,7 @@ export function acquireTracingState(): TracingState { countedMessages: new Set(), sessionProjects: new Map(), sessionIdentity: new Map(), + sessionTitles: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), activePrompts: new Map(), diff --git a/src/types.ts b/src/types.ts index 07468e0..51c77ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -164,6 +164,7 @@ export type TracingState = { countedMessages: Set sessionProjects: Map sessionIdentity: Map + sessionTitles: Map stepOutputs: Map> pendingPrompts: Map activePrompts: Map diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index e49e046..c3074fa 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -8,6 +8,7 @@ import { handlePromptEnqueued, handleSessionCreated, handleSessionIdle, + handleSessionRenamed, handleSessionStatus, handleRetryScheduled, handleUsageUpdated, @@ -247,3 +248,72 @@ describe("handleSessionIdle", () => { expect(histograms.sessionDuration.calls).toHaveLength(1) }) }) + +describe("session title", () => { + test("puts a title known at creation on the run span", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans[0]!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("puts the title on a subagent run span", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleSessionCreated(evt("session.created", { sessionID: "sub_1", parentID: "ses_1", title: "Explore the repo" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "sub_1" }), ctx) + const subRun = tracer.spans.find((s) => s.attributes["session.id"] === "sub_1")! + expect(subRun.attributes["session.title"]).toBe("Explore the repo") + }) + + test("stamps a later title on the open run span", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleSessionRenamed(evt("session.renamed", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + expect(tracer.spans[0]!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("puts a known title on a run span started afterwards", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleSessionRenamed(evt("session.renamed", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans[0]!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("leaves session.title unset for an unnamed session", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect("session.title" in tracer.spans[0]!.attributes).toBe(false) + }) + + test("ignores an empty title and keeps the one it has", () => { + const { ctx } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + handleSessionRenamed(evt("session.renamed", { sessionID: "ses_1", title: "" }), ctx) + expect(ctx.tracing.sessionTitles.get("ses_1")).toBe("Fix the flaky test") + }) + + test("keeps the title for the next execution after finalization", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleSessionRenamed(evt("session.renamed", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "ses_1" }), ctx, { type: "succeeded" }) + finalizeSession("ses_1", ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans.at(-1)!.attributes["session.title"]).toBe("Fix the flaky test") + }) + + test("records the title without spans when session traces are disabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], ["session"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleSessionRenamed(evt("session.renamed", { sessionID: "ses_1", title: "Fix the flaky test" }), ctx) + expect(tracer.spans).toHaveLength(0) + expect(ctx.tracing.sessionTitles.get("ses_1")).toBe("Fix the flaky test") + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 09175ee..8ca6276 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -154,6 +154,7 @@ export function makeTracingState(): TracingState { countedMessages: new Set(), sessionProjects: new Map(), sessionIdentity: new Map(), + sessionTitles: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), activePrompts: new Map(),