Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.<name>` | 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`:
Expand Down
23 changes: 21 additions & 2 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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"
Expand All @@ -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,
},
},
Expand Down Expand Up @@ -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)) {
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -162,6 +164,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
assistantRuns,
pendingRuns,
runInputs,
sessionTitles,
sessionSpans,
sessionSpanContexts,
messageSpans,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export type HandlerContext = {
assistantRuns: Map<string, string>
pendingRuns: Map<string, PendingRun>
runInputs: Map<string, string>
sessionTitles: Map<string, string>
sessionSpans: Map<string, Span>
sessionSpanContexts: Map<string, SpanContext>
messageSpans: Map<string, Span>
Expand Down
85 changes: 82 additions & 3 deletions tests/handlers/session.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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
}
Expand Down Expand Up @@ -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")
})
})
1 change: 1 addition & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading