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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ All configuration is via environment variables. Set them in your shell profile (
| `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` |
| `OPENCODE_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. |
| `OPENCODE_TRACEPARENT` | *(unset)* | W3C [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) string. When set, all spans are parented under this remote context so opencode traces nest inside a caller's trace (e.g. a CI job). Invalid values are logged and ignored. Note: with the default `ParentBased` sampler, a value with the sampled flag off (`...-00`) suppresses all trace export. |
| `OPENCODE_TRACESTATE` | *(unset)* | W3C [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) string, parsed alongside `OPENCODE_TRACEPARENT` and attached to the remote parent context. Ignored unless a valid `OPENCODE_TRACEPARENT` is also set. |

### Quick start

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"@opencode-ai/plugin": "^1.14.20",
"@opencode-ai/sdk": "^1.14.20",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.6.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.213.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.213.0",
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type PluginConfig = {
otlpHeaders: string | undefined
otlpHeadersHelper: string | undefined
resourceAttributes: string | undefined
traceparent: string | undefined
tracestate: string | undefined
metricsTemporality: MetricsTemporality | undefined
disabledMetrics: Set<string>
disabledTraces: Set<string>
Expand Down Expand Up @@ -65,6 +67,8 @@ export function loadConfig(): PluginConfig {
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
const otlpHeadersHelper = process.env["OPENCODE_OTLP_HEADERS_HELPER"]
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]
const traceparent = process.env["OPENCODE_TRACEPARENT"]
const tracestate = process.env["OPENCODE_TRACESTATE"]
const rawTemporality = process.env["OPENCODE_OTLP_METRICS_TEMPORALITY"]
const protocol = process.env["OPENCODE_OTLP_PROTOCOL"]

Expand Down Expand Up @@ -109,6 +113,8 @@ export function loadConfig(): PluginConfig {
otlpHeaders,
otlpHeadersHelper,
resourceAttributes,
traceparent,
tracestate,
metricsTemporality,
disabledMetrics,
disabledTraces,
Expand Down
17 changes: 10 additions & 7 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SeverityNumber } from "@opentelemetry/api-logs"
import { SpanStatusCode, SpanKind, context, trace } from "@opentelemetry/api"
import { SpanStatusCode, SpanKind, trace } from "@opentelemetry/api"
import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, ToolPart } from "@opencode-ai/sdk"
import {
AGENT_NAME,
Expand Down Expand Up @@ -256,9 +256,10 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
const toolSpan = isTraceEnabled("tool", ctx)
? (() => {
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
const baseCtx = ctx.rootContext()
const parentCtx = sessionSpan
? trace.setSpan(context.active(), sessionSpan)
: context.active()
? trace.setSpan(baseCtx, sessionSpan)
: baseCtx
return ctx.tracer.startSpan(
`${ctx.tracePrefix}tool.${toolPart.tool}`,
{
Expand Down Expand Up @@ -311,9 +312,10 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
if (isTraceEnabled("tool", ctx)) {
const toolSpan = pending?.span ?? (() => {
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
const baseCtx = ctx.rootContext()
const parentCtx = sessionSpan
? trace.setSpan(context.active(), sessionSpan)
: context.active()
? trace.setSpan(baseCtx, sessionSpan)
: baseCtx
return ctx.tracer.startSpan(
`${ctx.tracePrefix}tool.${toolPart.tool}`,
{
Expand Down Expand Up @@ -408,9 +410,10 @@ export function startMessageSpan(
const msgKey = `${sessionID}:${messageID}`
if (ctx.messageSpans.has(msgKey)) return
const sessionSpan = ctx.sessionSpans.get(sessionID)
const baseCtx = ctx.rootContext()
const parentCtx = sessionSpan
? trace.setSpan(context.active(), sessionSpan)
: context.active()
? trace.setSpan(baseCtx, sessionSpan)
: baseCtx

const msgSpan = ctx.tracer.startSpan(
`${ctx.tracePrefix}llm`,
Expand Down
12 changes: 6 additions & 6 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SeverityNumber } from "@opentelemetry/api-logs"
import { SpanStatusCode, context, trace } from "@opentelemetry/api"
import { SpanStatusCode, trace } from "@opentelemetry/api"
import type { EventSessionCreated, EventSessionIdle, 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"
Expand All @@ -18,14 +18,14 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" })

// WARNING: disabling "session" traces while "llm" or "tool" traces remain enabled
// will cause those child spans to be emitted as unlinked root spans with no parent.
// There is no session span to parent them to. If you need a connected trace hierarchy,
// either enable all three trace types or disable all of them together.
// leaves those child spans without a local session parent. If OPENCODE_TRACEPARENT
// is set, they fall back to that remote parent; otherwise they become root spans.
if (isTraceEnabled("session", ctx)) {
const parentSpan = parentID ? ctx.sessionSpans.get(parentID) : undefined
const baseCtx = ctx.rootContext()
const spanCtx = parentSpan
? trace.setSpan(context.active(), parentSpan)
: context.active()
? trace.setSpan(baseCtx, parentSpan)
: baseCtx

const sessionSpan = ctx.tracer.startSpan(
`${ctx.tracePrefix}session`,
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Plugin } from "@opencode-ai/plugin"
import { SeverityNumber } from "@opentelemetry/api-logs"
import { logs } from "@opentelemetry/api-logs"
import { trace } from "@opentelemetry/api"
import { ROOT_CONTEXT, trace } from "@opentelemetry/api"
import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions"
import pkg from "../package.json" with { type: "json" }
import type {
Expand All @@ -20,6 +20,7 @@ import { LEVELS, type Level, type HandlerContext } from "./types.ts"
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 { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
Expand Down Expand Up @@ -91,6 +92,11 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
logger.emit(record)
}
const tracer = trace.getTracer("com.opencode")
const remoteContext = remoteParentContext(config.traceparent, config.tracestate)
if (config.traceparent && !remoteContext) {
await log("warn", "invalid OPENCODE_TRACEPARENT ignored", { traceparentLength: config.traceparent.length })
}
const rootContext = remoteContext ? () => remoteContext : () => ROOT_CONTEXT
const pendingToolSpans = new Map()
const pendingPermissions = new Map()
const sessionTotals = new Map()
Expand Down Expand Up @@ -127,6 +133,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
disabledTraces,
tracer,
tracePrefix: config.metricPrefix,
rootContext,
sessionSpans,
messageSpans,
sessionInputs,
Expand Down
13 changes: 13 additions & 0 deletions src/trace-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defaultTextMapGetter, ROOT_CONTEXT, trace, type Context } from "@opentelemetry/api"
import { W3CTraceContextPropagator } from "@opentelemetry/core"

const propagator = new W3CTraceContextPropagator()

/** Builds a remote parent context from W3C trace-context headers. */
export function remoteParentContext(traceparent: string | undefined, tracestate: string | undefined): Context | undefined {
if (!traceparent) return undefined

const carrier = tracestate ? { traceparent, tracestate } : { traceparent }
const extracted = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter)
return trace.getSpanContext(extracted) ? extracted : undefined
}
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
import type { Context, Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
import type { LogRecord } from "@opentelemetry/api-logs"

/** Numeric priority map for log levels; higher value = higher severity. */
Expand Down Expand Up @@ -77,6 +77,7 @@ export type HandlerContext = {
disabledTraces: Set<string>
tracer: Tracer
tracePrefix: string
rootContext: () => Context
sessionSpans: Map<string, Span>
messageSpans: Map<string, Span>
sessionInputs: Map<string, string>
Expand Down
10 changes: 10 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ describe("loadConfig", () => {
"OPENCODE_OTLP_HEADERS",
"OPENCODE_OTLP_HEADERS_HELPER",
"OPENCODE_RESOURCE_ATTRIBUTES",
"OPENCODE_TRACEPARENT",
"OPENCODE_TRACESTATE",
"OPENCODE_OTLP_METRICS_TEMPORALITY",
"OPENCODE_DISABLE_METRICS",
"OPENCODE_DISABLE_LOGS",
Expand Down Expand Up @@ -134,6 +136,14 @@ describe("loadConfig", () => {
expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("team=platform,env=prod")
})

test("reads OPENCODE trace context", () => {
process.env["OPENCODE_TRACEPARENT"] = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
process.env["OPENCODE_TRACESTATE"] = "vendor=value"
const cfg = loadConfig()
expect(cfg.traceparent).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
expect(cfg.tracestate).toBe("vendor=value")
})

test("does not set OTEL_EXPORTER_OTLP_HEADERS when OPENCODE_OTLP_HEADERS is unset", () => {
delete process.env["OPENCODE_OTLP_HEADERS"]
loadConfig()
Expand Down
27 changes: 26 additions & 1 deletion tests/handlers/spans.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"
import { SpanStatusCode } from "@opentelemetry/api"
import { context, SpanStatusCode, trace, TraceFlags } from "@opentelemetry/api"
import {
AGENT_NAME,
LLM_MODEL_NAME,
Expand All @@ -18,6 +18,7 @@ import {
import type { Span } from "@opentelemetry/api"
import { handleSessionCreated, handleSessionIdle, 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,
Expand Down Expand Up @@ -131,6 +132,30 @@ describe("session spans", () => {
expect(tracer.spans[0]!.attributes["session.is_subagent"]).toBe(true)
})

test("root session span is parented to injected remote context", () => {
const { ctx, tracer } = makeCtx()
const rootContext = remoteParentContext("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", undefined)
expect(rootContext).toBeDefined()
ctx.rootContext = () => rootContext!
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
expect(tracer.spans[0]!.parentSpan?.spanContext().traceId).toBe("0af7651916cd43dd8448eb211c80319c")
expect(tracer.spans[0]!.parentSpan?.spanContext().spanId).toBe("b7ad6b7169203331")
})

test("root session span resolves root context at span creation", () => {
const { ctx, tracer } = makeCtx()
let rootContext = context.active()
ctx.rootContext = () => rootContext
rootContext = trace.setSpanContext(context.active(), {
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
spanId: "00f067aa0ba902b7",
traceFlags: TraceFlags.SAMPLED,
})
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
expect(tracer.spans[0]!.parentSpan?.spanContext().traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736")
expect(tracer.spans[0]!.parentSpan?.spanContext().spanId).toBe("00f067aa0ba902b7")
})

test("ends session span with OK status on session.idle", () => {
const { ctx, tracer } = makeCtx()
handleSessionCreated(makeSessionCreated("ses_1"), ctx)
Expand Down
3 changes: 2 additions & 1 deletion tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { HandlerContext, Instruments } from "../src/types.ts"
import type { LogRecord } from "@opentelemetry/api-logs"
import type { Counter, Gauge, Histogram, Span, SpanOptions, Tracer, Context, SpanContext, SpanStatus, Attributes } from "@opentelemetry/api"
import { SpanStatusCode, trace } from "@opentelemetry/api"
import { ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api"

export type SpyCounter = {
calls: Array<{ value: number; attrs: Record<string, unknown> }>
Expand Down Expand Up @@ -202,6 +202,7 @@ export function makeCtx(projectID = "proj_test", disabledMetrics: string[] = [],
disabledTraces: new Set(disabledTraces),
tracer: tracer as unknown as Tracer,
tracePrefix: "opencode.",
rootContext: () => ROOT_CONTEXT,
sessionSpans: new Map(),
messageSpans: new Map(),
sessionInputs: new Map(),
Expand Down
55 changes: 55 additions & 0 deletions tests/trace-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { context, ROOT_CONTEXT, trace, TraceFlags } from "@opentelemetry/api"
import { remoteParentContext } from "../src/trace-context.ts"

describe("remoteParentContext", () => {
test("returns undefined when traceparent is absent", () => {
expect(remoteParentContext(undefined, undefined)).toBeUndefined()
})

test("returns undefined for malformed traceparent", () => {
expect(remoteParentContext("not-a-traceparent", undefined)).toBeUndefined()
})

test("does not inherit the active context for malformed traceparent", () => {
const active = trace.setSpanContext(ROOT_CONTEXT, {
traceId: "0af7651916cd43dd8448eb211c80319c",
spanId: "b7ad6b7169203331",
traceFlags: TraceFlags.SAMPLED,
})
const ctx = context.with(active, () => remoteParentContext("not-a-traceparent", undefined))
expect(ctx).toBeUndefined()
})

test("returns a remote sampled parent context", () => {
const ctx = remoteParentContext(
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"vendor=value",
)
const spanCtx = ctx ? trace.getSpanContext(ctx) : undefined
expect(spanCtx?.traceId).toBe("0af7651916cd43dd8448eb211c80319c")
expect(spanCtx?.spanId).toBe("b7ad6b7169203331")
expect(spanCtx?.traceFlags).toBe(TraceFlags.SAMPLED)
expect(spanCtx?.isRemote).toBe(true)
expect(spanCtx?.traceState?.serialize()).toBe("vendor=value")
})

test("rejects zero trace and span ids", () => {
expect(remoteParentContext("00-00000000000000000000000000000000-b7ad6b7169203331-01", undefined)).toBeUndefined()
expect(remoteParentContext("00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01", undefined)).toBeUndefined()
})

test("rejects version 00 with trailing data", () => {
expect(remoteParentContext("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra", undefined)).toBeUndefined()
})

test("accepts trailing data for future versions", () => {
const ctx = remoteParentContext("01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra", undefined)
expect(ctx ? trace.getSpanContext(ctx)?.traceId : undefined).toBe("0af7651916cd43dd8448eb211c80319c")
})

test("preserves parsed trace flags", () => {
const ctx = remoteParentContext("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", undefined)
expect(ctx ? trace.getSpanContext(ctx)?.traceFlags : undefined).toBe(3)
})
})
Loading