From 14643b9b73eeef9f5663c252803074bda8875268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schemaitat?= Date: Mon, 6 Jul 2026 07:49:09 +0200 Subject: [PATCH 1/2] feat(tracing): add opt-in long-running session spans Long-running opencode sessions currently get a new root opencode.session span per turn, so a session spanning many turns shows up as disconnected traces instead of one cohesive trace. Set OPENCODE_LONG_RUNNING_SESSION_SPANS to keep a single root span open for a primary session's entire lifetime (session.created through session.deleted), with every turn's LLM/tool spans nesting under it. Off by default to preserve existing behavior; subagent sessions already nest under their parent regardless of the flag. Co-Authored-By: Claude Sonnet 5 --- README.md | 15 ++++++ src/config.ts | 3 ++ src/handlers/session.ts | 73 ++++++++++++++++++------- src/index.ts | 29 ++++++++-- src/types.ts | 1 + tests/config.test.ts | 13 +++++ tests/handlers/session.test.ts | 30 +++++++++-- tests/handlers/spans.test.ts | 98 +++++++++++++++++++++++++++++++--- tests/helpers.ts | 2 + 9 files changed, 233 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1e75f8f..06b7dc6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [Disabling specific metrics](#disabling-specific-metrics) - [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs) - [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces) + - [Long-running session spans (`OPENCODE_LONG_RUNNING_SESSION_SPANS`)](#long-running-session-spans) - [SigNoz example](#signoz-example) - [Datadog example](#datadog-example) - [Honeycomb example](#honeycomb-example) @@ -100,6 +101,7 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `OPENCODE_DISABLE_METRICS` | *(unset)* | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) | | `OPENCODE_DISABLE_LOGS` | *(unset)* | Set to any non-empty value to suppress all OTLP log events while leaving metrics and traces unchanged | | `OPENCODE_DISABLE_TRACES` | *(unset)* | Comma-separated list of trace types to disable (`session`, `llm`, `tool`). Use `all`, `*`, `true`, or `1` to disable every trace type | +| `OPENCODE_LONG_RUNNING_SESSION_SPANS` | *(unset)* | Set to any non-empty value to keep one root `opencode.session` span open for the full lifetime of a primary session, instead of a new root span per turn. See [Long-running session spans](#long-running-session-spans). | | `OPENCODE_OTLP_HEADERS` | *(unset)* | Comma-separated `key=value` headers added to all OTLP exports. **Keep out of version control — may contain sensitive auth tokens.** | | `OPENCODE_OTLP_HEADERS_HELPER` | *(unset)* | Executable script/binary that returns dynamic OTLP headers as JSON after an auth failure. Helper headers override `OPENCODE_OTLP_HEADERS`. | | `OPENCODE_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` | @@ -148,6 +150,7 @@ Option keys mirror the resolved config and map to the environment variables: | `metricsTemporality` | `OPENCODE_OTLP_METRICS_TEMPORALITY` | | `disabledMetrics` | `OPENCODE_DISABLE_METRICS` (array, not a comma string) | | `disabledTraces` | `OPENCODE_DISABLE_TRACES` (array, not a comma string) | +| `longRunningSessionSpans` | `OPENCODE_LONG_RUNNING_SESSION_SPANS` | > **Security note:** `opencode.json` is frequently committed to version control. Keep secrets such as `otlpHeaders` in an environment variable or an opencode `{env:VAR}` substitution (e.g. `"otlpHeaders": "{env:OTEL_HEADERS}"`) rather than inline. @@ -271,6 +274,18 @@ export OPENCODE_DISABLE_TRACES="all" Accepted explicit "disable all traces" values are `all`, `*`, `true`, and `1`. +### Long-running session spans + +By default, each user turn in a primary (non-subagent) session starts its own root `opencode.session` span, ended when that turn goes idle. This keeps traces short-lived, but means a long-running opencode session (many turns over minutes or hours) shows up as a series of disconnected traces rather than one cohesive trace. + +Set `OPENCODE_LONG_RUNNING_SESSION_SPANS` to keep a single root `opencode.session` span open for the entire lifetime of a primary session instead: it starts on `session.created` and stays open across `session.idle`, so every turn's LLM and tool spans nest under it, only ending on `session.deleted` (or on session error/shutdown). Subagent sessions already behave this way regardless of this flag, since they're expected to nest under their parent for the session's duration. + +```bash +export OPENCODE_LONG_RUNNING_SESSION_SPANS=1 +``` + +Turn this on if you want one trace per opencode session in your backend rather than one trace per turn. Leave it unset if your backend or dashboards assume shorter, per-turn traces. + ### SigNoz example ```bash diff --git a/src/config.ts b/src/config.ts index 9be26f7..4a987e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ export type PluginConfig = { metricsTemporality: MetricsTemporality | undefined disabledMetrics: Set disabledTraces: Set + longRunningSessionSpans: boolean } export function parseAttributePairs(raw: string | undefined): Record { @@ -69,6 +70,7 @@ export type OtelPluginOptions = { metricsTemporality?: MetricsTemporality disabledMetrics?: string[] disabledTraces?: string[] + longRunningSessionSpans?: boolean } const VALID_PROTOCOLS = new Set(["grpc", "http/protobuf", "http/json"]) @@ -199,6 +201,7 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { metricsTemporality, disabledMetrics, disabledTraces, + longRunningSessionSpans: pickBoolean(resolvedOptions.longRunningSessionSpans) ?? hasNonEmptyEnv("OPENCODE_LONG_RUNNING_SESSION_SPANS"), } } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index b03a14c..72bf391 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, EventSessionDeleted, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" import { AGENT_NAME, INPUT_MIME_TYPE, @@ -92,7 +92,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext } setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown", agentType }) - if (isTraceEnabled("session", ctx) && parentID) { + if (isTraceEnabled("session", ctx) && (parentID || ctx.longRunningSessionSpans)) { const sessionSpan = ctx.tracer.startSpan( `${ctx.tracePrefix}session`, { @@ -106,7 +106,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext ...ctx.commonAttrs, }, }, - resolveSessionTraceContext(parentID, ctx), + parentID ? resolveSessionTraceContext(parentID, ctx) : ctx.rootContext(), ) ctx.sessionSpans.set(sessionID, sessionSpan) setBoundedMap(ctx.sessionSpanContexts, sessionID, sessionSpan.spanContext()) @@ -154,13 +154,22 @@ 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 are kept open across `session.idle` events. + * When `OPENCODE_LONG_RUNNING_SESSION_SPANS` is enabled, the root `opencode.session` + * span for primary sessions is kept open here too: ending it 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) const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) - ctx.sessionTotals.delete(sessionID) - ctx.sessionDiffTotals.delete(sessionID) sweepSession(sessionID, ctx) const attrs = { ...ctx.commonAttrs, "session.id": sessionID } @@ -180,19 +189,14 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { } const sessionSpan = ctx.sessionSpans.get(sessionID) - if (sessionSpan) { - if (totals) { - sessionSpan.setAttributes({ - [AGENT_NAME]: totals.agent, - "agent.type": totals.agentType, - "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, + "agent.type": totals.agentType, + "session.total_tokens": totals.tokens, + "session.total_cost_usd": totals.cost, + "session.total_messages": totals.messages, + }) } const runID = ctx.activeRuns.get(sessionID) if (runID) ctx.activeRuns.delete(sessionID) @@ -234,6 +238,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 diff --git a/src/index.ts b/src/index.ts index 75bc279..3d6b206 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -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, handleSessionDeleted, handleSessionError, handleSessionStatus, 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" @@ -115,7 +116,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree const sessionSpanContexts = new Map() const messageSpans = new Map() const messageOutputs = new Map() - const { disabledMetrics, disabledTraces } = config + const { disabledMetrics, disabledTraces, longRunningSessionSpans } = config const commonAttrs = { ...parseAttributePairs(config.spanAttributes), "project.id": project.id, @@ -129,6 +130,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree await log("info", "traces disabled", { disabled: [...disabledTraces] }) } + if (longRunningSessionSpans) { + await log("info", "long-running session spans enabled") + } + if (!config.logsEnabled) { await log("info", "OTLP log events disabled") } @@ -144,6 +149,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree sessionDiffTotals, disabledMetrics, disabledTraces, + longRunningSessionSpans, tracer, tracePrefix: config.metricPrefix, rootContext, @@ -170,6 +176,20 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree async function shutdown() { if (shuttingDown) return shuttingDown = true + 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 forceFlushOtel(providers) await Promise.allSettled([meterProvider.shutdown(), loggerProvider.shutdown(), tracerProvider.shutdown()]) } @@ -286,6 +306,9 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree handleSessionIdle(event as EventSessionIdle, ctx) await flushTelemetry("session.idle") break + case "session.deleted": + handleSessionDeleted(event as EventSessionDeleted, ctx) + break case "session.error": handleSessionError(event as EventSessionError, ctx) await flushTelemetry("session.error") diff --git a/src/types.ts b/src/types.ts index 2aa33cb..b29d5f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,7 @@ export type HandlerContext = { sessionDiffTotals: Map disabledMetrics: Set disabledTraces: Set + longRunningSessionSpans: boolean tracer: Tracer tracePrefix: string rootContext: () => Context diff --git a/tests/config.test.ts b/tests/config.test.ts index deb96fe..aeab344 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -75,6 +75,7 @@ describe("loadConfig", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_LONG_RUNNING_SESSION_SPANS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -90,6 +91,7 @@ describe("loadConfig", () => { expect(cfg.protocol).toBe("grpc") expect(cfg.metricsInterval).toBe(60000) expect(cfg.logsInterval).toBe(5000) + expect(cfg.longRunningSessionSpans).toBe(false) }) test("enabled when OPENCODE_ENABLE_TELEMETRY is set", () => { @@ -102,6 +104,16 @@ describe("loadConfig", () => { expect(loadConfig().logsEnabled).toBe(false) }) + test("longRunningSessionSpans is true when OPENCODE_LONG_RUNNING_SESSION_SPANS is set", () => { + process.env["OPENCODE_LONG_RUNNING_SESSION_SPANS"] = "1" + expect(loadConfig().longRunningSessionSpans).toBe(true) + }) + + test("option longRunningSessionSpans wins over OPENCODE_LONG_RUNNING_SESSION_SPANS", () => { + process.env["OPENCODE_LONG_RUNNING_SESSION_SPANS"] = "1" + expect(loadConfig({ longRunningSessionSpans: false }).longRunningSessionSpans).toBe(false) + }) + test("reads custom endpoint", () => { process.env["OPENCODE_OTLP_ENDPOINT"] = "http://collector:4317" expect(loadConfig().endpoint).toBe("http://collector:4317") @@ -341,6 +353,7 @@ describe("loadConfig options", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_LONG_RUNNING_SESSION_SPANS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index bbada8a..0ef22b9 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -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 { @@ -153,12 +153,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", 1000, "ses_parent"), ctx) + handleSessionDeleted(makeSessionDeleted("ses_1"), ctx) + const span = tracer.spans[0]! + expect(span.ended).toBe(true) + expect(ctx.sessionSpans.has("ses_1")).toBe(false) }) }) diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index f80a7fa..69c2b6c 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -16,13 +16,14 @@ import { TOOL_NAME, } from "@arizeai/openinference-semantic-conventions" import type { Span } from "@opentelemetry/api" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleRunStarted } from "../../src/handlers/session.ts" +import { handleSessionCreated, handleSessionIdle, handleSessionDeleted, handleSessionError, handleRunStarted } 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, @@ -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", @@ -100,13 +105,22 @@ function makeToolPartUpdated( } describe("session spans", () => { - test("does not start a root trace span on session.created for primary sessions", () => { + test("does not start a root trace span on session.created for primary sessions by default", () => { const { ctx, tracer } = makeCtx() handleSessionCreated(makeSessionCreated("ses_1", 5000), ctx) expect(tracer.spans).toHaveLength(0) expect(ctx.sessionSpans.has("ses_1")).toBe(false) }) + test("starts a long-lived root session span on session.created for primary sessions when OPENCODE_LONG_RUNNING_SESSION_SPANS is enabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], [], true, {}, true) + handleSessionCreated(makeSessionCreated("ses_1", 5000), ctx) + expect(tracer.spans).toHaveLength(1) + expect(ctx.sessionSpans.has("ses_1")).toBe(true) + expect(tracer.spans[0]!.attributes["session.is_subagent"]).toBe(false) + expect(tracer.spans[0]!.attributes["agent.type"]).toBe("primary") + }) + test("subagent session span carries session.id attribute", () => { const { ctx, tracer } = makeCtx("proj_test", [], [], true, { team: "platform" }) handleRunStarted("user_parent", "ses_parent", "build", "prompt", "anthropic/claude", 900, ctx) @@ -182,6 +196,76 @@ describe("session spans", () => { expect(span.attributes["agent.type"]).toBe("primary") }) + test("keeps subagent session span open across session.idle so later turns can nest under it", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + const span = tracer.spans[0]! + 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 on subagent session.idle without ending the span", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "review", agentType: "subagent" }) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + const span = tracer.spans[0]! + 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.attributes[AGENT_NAME]).toBe("review") + expect(span.attributes["agent.type"]).toBe("subagent") + expect(span.ended).toBe(false) + }) + + test("ends subagent session span with OK status and clears totals on session.deleted", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "review", agentType: "subagent" }) + 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 subagent session span after idle", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + startMessageSpan("ses_1", "msg_2", "user_2", "claude-3-5-sonnet", "anthropic", 5000, ctx) + const sessionSpan = tracer.spans[0]! + const llmSpan = tracer.spans[1]! + expect(llmSpan.parentSpan).toBe(sessionSpan) + }) + + test("primary session: later turns' LLM and tool spans nest under one long-lived session span across idle when OPENCODE_LONG_RUNNING_SESSION_SPANS is enabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], [], true, {}, true) + handleSessionCreated(makeSessionCreated("ses_1", 1000), ctx) + const sessionSpan = tracer.spans[0]! + // turn 1 completes, session goes idle but the primary session span stays open + handleSessionIdle(makeSessionIdle("ses_1"), ctx) + expect(sessionSpan.ended).toBe(false) + // turn 2: a new LLM message and a tool call must nest under the same session span + startMessageSpan("ses_1", "msg_2", "user_2", "claude-3-5-sonnet", "anthropic", 5000, ctx) + handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 5100 }), ctx) + const llmSpan = tracer.spans[1]! + const toolSpan = tracer.spans[2]! + expect(llmSpan.parentSpan).toBe(sessionSpan) + expect(toolSpan.parentSpan).toBe(sessionSpan) + // one cohesive trace, not orphaned roots + expect(llmSpan.spanContext().traceId).toBe(sessionSpan.spanContext().traceId) + expect(toolSpan.spanContext().traceId).toBe(sessionSpan.spanContext().traceId) + // finalized only on delete + handleSessionDeleted(makeSessionDeleted("ses_1"), ctx) + expect(sessionSpan.ended).toBe(true) + }) + test("ends run span with ERROR status on session.error", () => { const { ctx, tracer } = makeCtx() handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) @@ -566,9 +650,10 @@ describe("OPENCODE_DISABLE_TRACES=llm", () => { }) test("session spans still created when only llm disabled", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["llm"]) + const { ctx, tracer } = makeCtx("proj_test", [], ["llm"], true, {}, true) handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(tracer.spans).toHaveLength(0) + expect(tracer.spans).toHaveLength(1) + expect(ctx.sessionSpans.has("ses_1")).toBe(true) }) }) @@ -609,8 +694,9 @@ describe("OPENCODE_DISABLE_TRACES=tool", () => { }) test("session spans still created when only tool disabled", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["tool"]) + const { ctx, tracer } = makeCtx("proj_test", [], ["tool"], true, {}, true) handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(tracer.spans).toHaveLength(0) + expect(tracer.spans).toHaveLength(1) + expect(ctx.sessionSpans.has("ses_1")).toBe(true) }) }) diff --git a/tests/helpers.ts b/tests/helpers.ts index 14d2bff..817ba4c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -177,6 +177,7 @@ export function makeCtx( disabledTraces: string[] = [], logsEnabled = true, extraCommonAttrs: Record = {}, + longRunningSessionSpans = false, ): MockContext { const session = makeCounter() const token = makeCounter() @@ -229,6 +230,7 @@ export function makeCtx( sessionDiffTotals: new Map(), disabledMetrics: new Set(disabledMetrics), disabledTraces: new Set(disabledTraces), + longRunningSessionSpans, tracer: tracer as unknown as Tracer, tracePrefix: "opencode.", rootContext: () => ROOT_CONTEXT, From 4bf139f0546c143856f8e41c8f59e6fa6162057f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schemaitat?= Date: Mon, 6 Jul 2026 08:14:51 +0200 Subject: [PATCH 2/2] fix(tracing): set agent.type and emit session.deleted log consistently handleSessionDeleted and the shutdown finalization loop set agent name but not agent.type on the closing span, unlike handleSessionIdle. Also add the missing session.deleted OTLP log event, matching the pattern used by session.created/session.idle/session.error. Co-Authored-By: Claude Sonnet 5 --- src/handlers/session.ts | 16 ++++++++++++++++ src/index.ts | 1 + tests/handlers/session.test.ts | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 72bf391..dd9916b 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -247,6 +247,8 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { export function handleSessionDeleted(e: EventSessionDeleted, ctx: HandlerContext) { const sessionID = e.properties.info.id const totals = ctx.sessionTotals.get(sessionID) + const agentName = totals?.agent ?? "unknown" + const agentType = totals?.agentType ?? "unknown" ctx.sessionTotals.delete(sessionID) ctx.sessionDiffTotals.delete(sessionID) sweepSession(sessionID, ctx) @@ -256,6 +258,7 @@ export function handleSessionDeleted(e: EventSessionDeleted, ctx: HandlerContext if (totals) { sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, + "agent.type": totals.agentType, "session.total_tokens": totals.tokens, "session.total_cost_usd": totals.cost, "session.total_messages": totals.messages, @@ -266,6 +269,19 @@ export function handleSessionDeleted(e: EventSessionDeleted, ctx: HandlerContext ctx.sessionSpans.delete(sessionID) } + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: Date.now(), + observedTimestamp: Date.now(), + body: "session.deleted", + attributes: { + "event.name": "session.deleted", + "session.id": sessionID, + ...agentAttrs(agentName, agentType), + ...ctx.commonAttrs, + }, + }) ctx.log("debug", "otel: session.deleted", { sessionID }) } diff --git a/src/index.ts b/src/index.ts index 3d6b206..92bc893 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,6 +181,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree if (totals) { sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, + "agent.type": totals.agentType, "session.total_tokens": totals.tokens, "session.total_cost_usd": totals.cost, "session.total_messages": totals.messages, diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index 0ef22b9..e571cbf 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -184,6 +184,26 @@ describe("handleSessionDeleted", () => { expect(span.ended).toBe(true) expect(ctx.sessionSpans.has("ses_1")).toBe(false) }) + + test("sets agent.type on the session span from the last known totals", async () => { + const { ctx, tracer } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + ctx.sessionTotals.set("ses_1", { startMs: 1000, tokens: 10, cost: 0.01, messages: 1, agent: "review", agentType: "subagent" }) + handleSessionDeleted(makeSessionDeleted("ses_1"), ctx) + const span = tracer.spans[0]! + expect(span.attributes["agent.type"]).toBe("subagent") + }) + + test("emits session.deleted log record", async () => { + const { ctx, logger } = makeCtx() + await handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) + ctx.sessionTotals.set("ses_1", { startMs: 1000, tokens: 10, cost: 0.01, messages: 1, agent: "review", agentType: "subagent" }) + handleSessionDeleted(makeSessionDeleted("ses_1"), ctx) + const record = logger.records.find(r => r.body === "session.deleted") + expect(record).toBeDefined() + expect(record!.attributes?.["session.id"]).toBe("ses_1") + expect(record!.attributes?.["agent"]).toBe("review") + }) }) describe("handleSessionError", () => {