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
66 changes: 50 additions & 16 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, trace } from "@opentelemetry/api"
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
import type { EventSessionCreated, EventSessionIdle, EventSessionDeleted, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
import { AGENT_NAME, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions"
import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts"
import type { HandlerContext } from "../types.ts"
Expand Down Expand Up @@ -80,12 +80,20 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
}
}

/** Emits a `session.idle` log event, records duration and session total histograms, ends the session span, and clears pending state. */
/**
* Emits a `session.idle` log event and records duration/total histograms for the turn
* that just completed, then sweeps per-turn pending state (tool/message spans, pending
* permissions, the cached user prompt).
*
* Unlike a one-shot turn, an opencode session stays alive and may receive further user
* messages, so `session.total_*` totals and the root `opencode.session` span are kept
* open across `session.idle` events: ending the span here would otherwise orphan every
* subsequent turn's LLM/tool spans as new root traces. The span and totals are only
* finalized in `handleSessionDeleted` (or `handleSessionError`/shutdown).
*/
export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
const sessionID = e.properties.sessionID
const totals = ctx.sessionTotals.get(sessionID)
ctx.sessionTotals.delete(sessionID)
ctx.sessionDiffTotals.delete(sessionID)
sweepSession(sessionID, ctx)

const attrs = { ...ctx.commonAttrs, "session.id": sessionID }
Expand All @@ -105,18 +113,13 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
}

const sessionSpan = ctx.sessionSpans.get(sessionID)
if (sessionSpan) {
if (totals) {
sessionSpan.setAttributes({
[AGENT_NAME]: totals.agent,
"session.total_tokens": totals.tokens,
"session.total_cost_usd": totals.cost,
"session.total_messages": totals.messages,
})
}
sessionSpan.setStatus({ code: SpanStatusCode.OK })
sessionSpan.end()
ctx.sessionSpans.delete(sessionID)
if (sessionSpan && totals) {
sessionSpan.setAttributes({
[AGENT_NAME]: totals.agent,
"session.total_tokens": totals.tokens,
"session.total_cost_usd": totals.cost,
"session.total_messages": totals.messages,
})
}

ctx.emitLog({
Expand All @@ -140,6 +143,37 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
})
}

/**
* Final cleanup when a session is removed: ends the root `opencode.session` span with the
* last known totals, and clears the session's accumulated totals and pending state. This is
* the counterpart to the "keep the span open across `session.idle`" behavior above — it's
* where a long-lived session's span and totals actually get torn down.
*/
export function handleSessionDeleted(e: EventSessionDeleted, ctx: HandlerContext) {
const sessionID = e.properties.info.id
const totals = ctx.sessionTotals.get(sessionID)
ctx.sessionTotals.delete(sessionID)
ctx.sessionDiffTotals.delete(sessionID)
sweepSession(sessionID, ctx)

const sessionSpan = ctx.sessionSpans.get(sessionID)
if (sessionSpan) {
if (totals) {
sessionSpan.setAttributes({
[AGENT_NAME]: totals.agent,
"session.total_tokens": totals.tokens,
"session.total_cost_usd": totals.cost,
"session.total_messages": totals.messages,
})
}
sessionSpan.setStatus({ code: SpanStatusCode.OK })
sessionSpan.end()
ctx.sessionSpans.delete(sessionID)
}

ctx.log("debug", "otel: session.deleted", { sessionID })
}

/** Emits a `session.error` log event, ends the session span with error status, and clears any pending state for the session. */
export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
const rawID = e.properties.sessionID
Expand Down
25 changes: 23 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Plugin } from "@opencode-ai/plugin"
import { SeverityNumber } from "@opentelemetry/api-logs"
import { logs } from "@opentelemetry/api-logs"
import { ROOT_CONTEXT, trace } from "@opentelemetry/api"
import { ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api"
import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions"
import pkg from "../package.json" with { type: "json" }
import type {
EventSessionCreated,
EventSessionIdle,
EventSessionDeleted,
EventSessionError,
EventSessionStatus,
EventMessageUpdated,
Expand All @@ -21,7 +22,7 @@ import { loadConfig, resolveHelperPath, resolveLogLevel } from "./config.ts"
import { probeEndpoint } from "./probe.ts"
import { setupOtel, createInstruments } from "./otel.ts"
import { remoteParentContext } from "./trace-context.ts"
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "./handlers/session.ts"
import { handleSessionCreated, handleSessionIdle, handleSessionDeleted, handleSessionError, handleSessionStatus } 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 @@ -141,6 +142,23 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}

async function shutdown() {
// Session spans are kept open across `session.idle` so later turns nest under them
// (see handleSessionIdle). On process exit, end any still-open session spans so
// they're flushed rather than dropped.
for (const [sessionID, sessionSpan] of sessionSpans) {
const totals = sessionTotals.get(sessionID)
if (totals) {
sessionSpan.setAttributes({
[AGENT_NAME]: totals.agent,
"session.total_tokens": totals.tokens,
"session.total_cost_usd": totals.cost,
"session.total_messages": totals.messages,
})
}
sessionSpan.setStatus({ code: SpanStatusCode.OK })
sessionSpan.end()
}
sessionSpans.clear()
await Promise.allSettled([meterProvider.shutdown(), loggerProvider.shutdown(), tracerProvider.shutdown()])
}

Expand Down Expand Up @@ -225,6 +243,9 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
case "session.idle":
handleSessionIdle(event as EventSessionIdle, ctx)
break
case "session.deleted":
handleSessionDeleted(event as EventSessionDeleted, ctx)
break
case "session.error":
handleSessionError(event as EventSessionError, ctx)
break
Expand Down
30 changes: 27 additions & 3 deletions tests/handlers/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test"
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "../../src/handlers/session.ts"
import { handleSessionCreated, handleSessionIdle, handleSessionDeleted, handleSessionError, handleSessionStatus } 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, EventSessionDeleted, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
import type { Span } from "@opentelemetry/api"

function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated {
Expand Down Expand Up @@ -151,12 +151,36 @@ describe("handleSessionIdle", () => {
expect(gauges.sessionCost.calls).toHaveLength(0)
})

test("removes sessionTotals entry on idle", async () => {
test("keeps sessionTotals entry across idle so later turns keep accumulating", async () => {
const { ctx } = makeCtx()
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
expect(ctx.sessionTotals.has("ses_1")).toBe(true)
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
expect(ctx.sessionTotals.has("ses_1")).toBe(true)
})
})

describe("handleSessionDeleted", () => {
function makeSessionDeleted(sessionID: string): EventSessionDeleted {
return { type: "session.deleted", properties: { info: { id: sessionID } } } as unknown as EventSessionDeleted
}

test("removes sessionTotals and sessionDiffTotals entries", async () => {
const { ctx } = makeCtx()
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
ctx.sessionDiffTotals.set("ses_1", { additions: 1, deletions: 2 })
handleSessionDeleted(makeSessionDeleted("ses_1"), ctx)
expect(ctx.sessionTotals.has("ses_1")).toBe(false)
expect(ctx.sessionDiffTotals.has("ses_1")).toBe(false)
})

test("ends the session span with OK status", async () => {
const { ctx, tracer } = makeCtx()
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
handleSessionDeleted(makeSessionDeleted("ses_1"), ctx)
const span = tracer.spans[0]!
expect(span.ended).toBe(true)
expect(ctx.sessionSpans.has("ses_1")).toBe(false)
})
})

Expand Down
42 changes: 36 additions & 6 deletions tests/handlers/spans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import {
TOOL_NAME,
} from "@arizeai/openinference-semantic-conventions"
import type { Span } from "@opentelemetry/api"
import { handleSessionCreated, handleSessionIdle, handleSessionError } from "../../src/handlers/session.ts"
import { handleSessionCreated, handleSessionIdle, handleSessionDeleted, handleSessionError } from "../../src/handlers/session.ts"
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "../../src/handlers/message.ts"
import { remoteParentContext } from "../../src/trace-context.ts"
import { makeCtx, makeTracer, type SpySpan } from "../helpers.ts"
import type {
EventSessionCreated,
EventSessionIdle,
EventSessionDeleted,
EventSessionError,
EventMessageUpdated,
EventMessagePartUpdated,
Expand All @@ -41,6 +42,10 @@ function makeSessionIdle(sessionID: string): EventSessionIdle {
return { type: "session.idle", properties: { sessionID } } as EventSessionIdle
}

function makeSessionDeleted(sessionID: string): EventSessionDeleted {
return { type: "session.deleted", properties: { info: { id: sessionID } } } as unknown as EventSessionDeleted
}

function makeSessionError(sessionID?: string, error?: { name: string }): EventSessionError {
return {
type: "session.error",
Expand Down Expand Up @@ -156,17 +161,17 @@ describe("session spans", () => {
expect(tracer.spans[0]!.parentSpan?.spanContext().spanId).toBe("00f067aa0ba902b7")
})

test("ends session span with OK status on session.idle", () => {
test("keeps session span open across session.idle so later turns can nest under it", () => {
const { ctx, tracer } = makeCtx()
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
const span = tracer.spans[0]!
expect(span.ended).toBe(true)
expect(span.status.code).toBe(SpanStatusCode.OK)
expect(ctx.sessionSpans.has("ses_1")).toBe(false)
expect(span.ended).toBe(false)
expect(ctx.sessionSpans.has("ses_1")).toBe(true)
expect(ctx.sessionTotals.has("ses_1")).toBe(true)
})

test("sets session total attributes before ending on idle", () => {
test("sets session total attributes on session.idle without ending the span", () => {
const { ctx, tracer } = makeCtx()
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "build" })
Expand All @@ -175,6 +180,31 @@ describe("session spans", () => {
expect(span.attributes["session.total_tokens"]).toBe(250)
expect(span.attributes["session.total_cost_usd"]).toBe(0.05)
expect(span.attributes["session.total_messages"]).toBe(3)
expect(span.ended).toBe(false)
})

test("ends session span with OK status and clears totals on session.deleted", () => {
const { ctx, tracer } = makeCtx()
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "build" })
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
handleSessionDeleted(makeSessionDeleted("ses_1"), ctx)
const span = tracer.spans[0]!
expect(span.ended).toBe(true)
expect(span.status.code).toBe(SpanStatusCode.OK)
expect(span.attributes["session.total_tokens"]).toBe(250)
expect(ctx.sessionSpans.has("ses_1")).toBe(false)
expect(ctx.sessionTotals.has("ses_1")).toBe(false)
})

test("a second turn's LLM span nests under the still-open session span after idle", () => {
const { ctx, tracer } = makeCtx()
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
startMessageSpan("ses_1", "msg_2", "claude-3-5-sonnet", "anthropic", 5000, ctx)
const sessionSpan = tracer.spans[0]!
const llmSpan = tracer.spans[1]!
expect(llmSpan.parentSpan).toBe(sessionSpan)
})

test("ends session span with ERROR status on session.error", () => {
Expand Down
Loading