Skip to content

Commit 1da0a85

Browse files
Merge pull request #57 from Savid/feat/traceparent-context
feat(trace): support remote W3C parent context
2 parents e50a9ed + 83e3d42 commit 1da0a85

13 files changed

Lines changed: 142 additions & 17 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ All configuration is via environment variables. Set them in your shell profile (
101101
| `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`. |
102102
| `OPENCODE_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` |
103103
| `OPENCODE_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. |
104+
| `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. |
105+
| `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. |
104106

105107
### Quick start
106108

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@opencode-ai/plugin": "^1.14.20",
99
"@opencode-ai/sdk": "^1.14.20",
1010
"@opentelemetry/api": "^1.9.0",
11+
"@opentelemetry/core": "^2.6.0",
1112
"@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0",
1213
"@opentelemetry/exporter-logs-otlp-http": "^0.213.0",
1314
"@opentelemetry/exporter-logs-otlp-proto": "^0.213.0",

src/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type PluginConfig = {
2121
otlpHeaders: string | undefined
2222
otlpHeadersHelper: string | undefined
2323
resourceAttributes: string | undefined
24+
traceparent: string | undefined
25+
tracestate: string | undefined
2426
metricsTemporality: MetricsTemporality | undefined
2527
disabledMetrics: Set<string>
2628
disabledTraces: Set<string>
@@ -65,6 +67,8 @@ export function loadConfig(): PluginConfig {
6567
const otlpHeaders = process.env["OPENCODE_OTLP_HEADERS"]
6668
const otlpHeadersHelper = process.env["OPENCODE_OTLP_HEADERS_HELPER"]
6769
const resourceAttributes = process.env["OPENCODE_RESOURCE_ATTRIBUTES"]
70+
const traceparent = process.env["OPENCODE_TRACEPARENT"]
71+
const tracestate = process.env["OPENCODE_TRACESTATE"]
6872
const rawTemporality = process.env["OPENCODE_OTLP_METRICS_TEMPORALITY"]
6973
const protocol = process.env["OPENCODE_OTLP_PROTOCOL"]
7074

@@ -109,6 +113,8 @@ export function loadConfig(): PluginConfig {
109113
otlpHeaders,
110114
otlpHeadersHelper,
111115
resourceAttributes,
116+
traceparent,
117+
tracestate,
112118
metricsTemporality,
113119
disabledMetrics,
114120
disabledTraces,

src/handlers/message.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
2-
import { SpanStatusCode, SpanKind, context, trace } from "@opentelemetry/api"
2+
import { SpanStatusCode, SpanKind, trace } from "@opentelemetry/api"
33
import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, ToolPart } from "@opencode-ai/sdk"
44
import {
55
AGENT_NAME,
@@ -256,9 +256,10 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
256256
const toolSpan = isTraceEnabled("tool", ctx)
257257
? (() => {
258258
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
259+
const baseCtx = ctx.rootContext()
259260
const parentCtx = sessionSpan
260-
? trace.setSpan(context.active(), sessionSpan)
261-
: context.active()
261+
? trace.setSpan(baseCtx, sessionSpan)
262+
: baseCtx
262263
return ctx.tracer.startSpan(
263264
`${ctx.tracePrefix}tool.${toolPart.tool}`,
264265
{
@@ -311,9 +312,10 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
311312
if (isTraceEnabled("tool", ctx)) {
312313
const toolSpan = pending?.span ?? (() => {
313314
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
315+
const baseCtx = ctx.rootContext()
314316
const parentCtx = sessionSpan
315-
? trace.setSpan(context.active(), sessionSpan)
316-
: context.active()
317+
? trace.setSpan(baseCtx, sessionSpan)
318+
: baseCtx
317319
return ctx.tracer.startSpan(
318320
`${ctx.tracePrefix}tool.${toolPart.tool}`,
319321
{
@@ -408,9 +410,10 @@ export function startMessageSpan(
408410
const msgKey = `${sessionID}:${messageID}`
409411
if (ctx.messageSpans.has(msgKey)) return
410412
const sessionSpan = ctx.sessionSpans.get(sessionID)
413+
const baseCtx = ctx.rootContext()
411414
const parentCtx = sessionSpan
412-
? trace.setSpan(context.active(), sessionSpan)
413-
: context.active()
415+
? trace.setSpan(baseCtx, sessionSpan)
416+
: baseCtx
414417

415418
const msgSpan = ctx.tracer.startSpan(
416419
`${ctx.tracePrefix}llm`,

src/handlers/session.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
2-
import { SpanStatusCode, context, trace } from "@opentelemetry/api"
2+
import { SpanStatusCode, trace } from "@opentelemetry/api"
33
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
44
import { AGENT_NAME, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions"
55
import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts"
@@ -18,14 +18,14 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
1818
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" })
1919

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

3030
const sessionSpan = ctx.tracer.startSpan(
3131
`${ctx.tracePrefix}session`,

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Plugin } from "@opencode-ai/plugin"
22
import { SeverityNumber } from "@opentelemetry/api-logs"
33
import { logs } from "@opentelemetry/api-logs"
4-
import { trace } from "@opentelemetry/api"
4+
import { ROOT_CONTEXT, trace } from "@opentelemetry/api"
55
import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions"
66
import pkg from "../package.json" with { type: "json" }
77
import type {
@@ -20,6 +20,7 @@ import { LEVELS, type Level, type HandlerContext } from "./types.ts"
2020
import { loadConfig, resolveHelperPath, resolveLogLevel } from "./config.ts"
2121
import { probeEndpoint } from "./probe.ts"
2222
import { setupOtel, createInstruments } from "./otel.ts"
23+
import { remoteParentContext } from "./trace-context.ts"
2324
import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "./handlers/session.ts"
2425
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
2526
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
@@ -91,6 +92,11 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
9192
logger.emit(record)
9293
}
9394
const tracer = trace.getTracer("com.opencode")
95+
const remoteContext = remoteParentContext(config.traceparent, config.tracestate)
96+
if (config.traceparent && !remoteContext) {
97+
await log("warn", "invalid OPENCODE_TRACEPARENT ignored", { traceparentLength: config.traceparent.length })
98+
}
99+
const rootContext = remoteContext ? () => remoteContext : () => ROOT_CONTEXT
94100
const pendingToolSpans = new Map()
95101
const pendingPermissions = new Map()
96102
const sessionTotals = new Map()
@@ -127,6 +133,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
127133
disabledTraces,
128134
tracer,
129135
tracePrefix: config.metricPrefix,
136+
rootContext,
130137
sessionSpans,
131138
messageSpans,
132139
sessionInputs,

src/trace-context.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defaultTextMapGetter, ROOT_CONTEXT, trace, type Context } from "@opentelemetry/api"
2+
import { W3CTraceContextPropagator } from "@opentelemetry/core"
3+
4+
const propagator = new W3CTraceContextPropagator()
5+
6+
/** Builds a remote parent context from W3C trace-context headers. */
7+
export function remoteParentContext(traceparent: string | undefined, tracestate: string | undefined): Context | undefined {
8+
if (!traceparent) return undefined
9+
10+
const carrier = tracestate ? { traceparent, tracestate } : { traceparent }
11+
const extracted = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter)
12+
return trace.getSpanContext(extracted) ? extracted : undefined
13+
}

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
1+
import type { Context, Counter, Gauge, Histogram, Span, Tracer } from "@opentelemetry/api"
22
import type { LogRecord } from "@opentelemetry/api-logs"
33

44
/** Numeric priority map for log levels; higher value = higher severity. */
@@ -77,6 +77,7 @@ export type HandlerContext = {
7777
disabledTraces: Set<string>
7878
tracer: Tracer
7979
tracePrefix: string
80+
rootContext: () => Context
8081
sessionSpans: Map<string, Span>
8182
messageSpans: Map<string, Span>
8283
sessionInputs: Map<string, string>

tests/config.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ describe("loadConfig", () => {
5050
"OPENCODE_OTLP_HEADERS",
5151
"OPENCODE_OTLP_HEADERS_HELPER",
5252
"OPENCODE_RESOURCE_ATTRIBUTES",
53+
"OPENCODE_TRACEPARENT",
54+
"OPENCODE_TRACESTATE",
5355
"OPENCODE_OTLP_METRICS_TEMPORALITY",
5456
"OPENCODE_DISABLE_METRICS",
5557
"OPENCODE_DISABLE_LOGS",
@@ -134,6 +136,14 @@ describe("loadConfig", () => {
134136
expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("team=platform,env=prod")
135137
})
136138

139+
test("reads OPENCODE trace context", () => {
140+
process.env["OPENCODE_TRACEPARENT"] = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
141+
process.env["OPENCODE_TRACESTATE"] = "vendor=value"
142+
const cfg = loadConfig()
143+
expect(cfg.traceparent).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
144+
expect(cfg.tracestate).toBe("vendor=value")
145+
})
146+
137147
test("does not set OTEL_EXPORTER_OTLP_HEADERS when OPENCODE_OTLP_HEADERS is unset", () => {
138148
delete process.env["OPENCODE_OTLP_HEADERS"]
139149
loadConfig()

0 commit comments

Comments
 (0)