Skip to content
Merged
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
5 changes: 1 addition & 4 deletions .github/workflows/discord-notify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ jobs:
notify:
name: Send Discord notification
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
pull-requests: read
permissions: {}
steps:
- name: Build payload
id: payload
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
- [Quick start](#quick-start)
- [Headers and resource attributes](#headers-and-resource-attributes)
- [Dynamic headers](#dynamic-headers)
- [LLM trace propagation](#llm-trace-propagation)
- [Disabling specific metrics](#disabling-specific-metrics)
- [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs)
- [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces)
Expand Down Expand Up @@ -107,6 +108,7 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba
| `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. |
| `OPENCODE_TRACE_PROPAGATION_PROVIDERS` | *(unset)* | Comma-separated opencode provider IDs that receive W3C `traceparent` and `tracestate` headers on LLM requests. Use `*` to explicitly enable every provider. |

### Plugin options (opencode.json)

Expand Down Expand Up @@ -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) |
| `tracePropagationProviders` | `OPENCODE_TRACE_PROPAGATION_PROVIDERS` (array, not a comma string) |

> **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.

Expand Down Expand Up @@ -209,6 +212,18 @@ For a Cloud Run collector using IAM authentication, `get-token.sh` might be `gcl

If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static headers with the same name. Header values are never logged.

### LLM trace propagation

Use `OPENCODE_TRACE_PROPAGATION_PROVIDERS` to connect this plugin's LLM spans to spans emitted by an LLM gateway such as LiteLLM or vLLM. For matching provider IDs, the plugin injects the current `opencode.llm` span as the W3C `traceparent` header and includes `tracestate` when present.

```bash
export OPENCODE_TRACE_PROPAGATION_PROVIDERS="company-litellm,vllm"
```

The values are opencode provider IDs, including custom names configured under the `provider` key in `opencode.json`. Propagation is disabled when the setting is unset. Use `*` only when every configured provider should receive trace context.

Only W3C trace context is propagated. The plugin does not inject arbitrary headers or W3C baggage. Configure static provider-specific headers through the provider's native `options.headers` setting in `opencode.json`.

### Disabling specific metrics

Use `OPENCODE_DISABLE_METRICS` to suppress individual metrics. The value is a comma-separated list of metric name suffixes (without the prefix).
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type PluginConfig = {
metricsTemporality: MetricsTemporality | undefined
disabledMetrics: Set<string>
disabledTraces: Set<string>
tracePropagationProviders: Set<string>
}

export function parseAttributePairs(raw: string | undefined): Record<string, string> {
Expand Down Expand Up @@ -69,6 +70,7 @@ export type OtelPluginOptions = {
metricsTemporality?: MetricsTemporality
disabledMetrics?: string[]
disabledTraces?: string[]
tracePropagationProviders?: string[]
}

const VALID_PROTOCOLS = new Set<PluginConfig["protocol"]>(["grpc", "http/protobuf", "http/json"])
Expand Down Expand Up @@ -182,6 +184,13 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
const optionTraces = pickStringList(resolvedOptions.disabledTraces)
const disabledTraces = expandDisabledTraces(optionTraces ?? splitList(process.env["OPENCODE_DISABLE_TRACES"]))

const optionTracePropagationProviders = pickStringList(resolvedOptions.tracePropagationProviders)
const tracePropagationProviders = new Set(
optionTracePropagationProviders
? normalizeList(optionTracePropagationProviders)
: splitList(process.env["OPENCODE_TRACE_PROPAGATION_PROVIDERS"]),
)

return {
enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"),
logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"),
Expand All @@ -199,6 +208,7 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig {
metricsTemporality,
disabledMetrics,
disabledTraces,
tracePropagationProviders,
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/handlers/chat-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ProviderContext } from "@opencode-ai/plugin"
import type { Model, UserMessage } from "@opencode-ai/sdk"
import type { HandlerContext } from "../types.ts"
import { injectTraceContext } from "../trace-context.ts"

/** Injects the matching LLM span context for explicitly enabled providers. */
export function handleChatHeaders(
input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
output: { headers: Record<string, string> },
ctx: HandlerContext,
): void {
const providerID = input.model.providerID
if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return

const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`)?.findLast(candidate =>
candidate.agent === input.agent
&& candidate.modelID === input.model.id
&& candidate.providerID === providerID
)
if (!request) return

injectTraceContext(request.spanContext, output.headers)
}
19 changes: 19 additions & 0 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
ctx.messageSpans.delete(msgKey)
ctx.messageOutputs.delete(msgKey)
}
const requestKey = `${sessionID}:${assistant.parentID}`
const remainingRequests = ctx.llmRequestContexts.get(requestKey)?.filter(request => request.messageID !== assistant.id)
if (remainingRequests?.length) {
setBoundedMap(ctx.llmRequestContexts, requestKey, remainingRequests)
} else {
ctx.llmRequestContexts.delete(requestKey)
}

if (assistant.error) {
ctx.emitLog({
Expand Down Expand Up @@ -424,6 +431,7 @@ export function startMessageSpan(
providerID: string,
startTime: number,
ctx: HandlerContext,
agent?: string,
) {
if (!isTraceEnabled("llm", ctx)) return
const msgKey = `${sessionID}:${messageID}`
Expand Down Expand Up @@ -459,4 +467,15 @@ export function startMessageSpan(
resolveSessionTraceContext(sessionID, ctx, { runID: parentID, assistantMessageID: messageID }),
)
setBoundedMap(ctx.messageSpans, msgKey, msgSpan)
const requestKey = `${sessionID}:${parentID}`
setBoundedMap(ctx.llmRequestContexts, requestKey, [
...(ctx.llmRequestContexts.get(requestKey) ?? []),
{
messageID,
agent: agent ?? agentName,
modelID,
providerID,
spanContext: msgSpan.spanContext(),
},
])
}
3 changes: 3 additions & 0 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
for (const key of ctx.messageOutputs.keys()) {
if (key.startsWith(msgPrefix)) ctx.messageOutputs.delete(key)
}
for (const key of ctx.llmRequestContexts.keys()) {
if (key.startsWith(msgPrefix)) ctx.llmRequestContexts.delete(key)
}
}

/** Emits a `session.idle` log event, records duration and session total histograms, ends the session span, and clears pending state. */
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
import { handleChatHeaders } from "./handlers/chat-headers.ts"
import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "./util.ts"
import type { SessionTotals } from "./types.ts"

Expand Down Expand Up @@ -115,6 +116,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
const sessionSpanContexts = new Map()
const messageSpans = new Map()
const messageOutputs = new Map()
const llmRequestContexts = new Map()
const { disabledMetrics, disabledTraces } = config
const commonAttrs = {
...parseAttributePairs(config.spanAttributes),
Expand Down Expand Up @@ -157,6 +159,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
sessionSpanContexts,
messageSpans,
messageOutputs,
llmRequestContexts,
tracePropagationProviders: config.tracePropagationProviders,
}

let shuttingDown = false
Expand Down Expand Up @@ -206,6 +210,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}
},

"chat.headers": safe("chat.headers", async (input, output) => {
handleChatHeaders(input, output, ctx)
}),

"chat.message": safe("chat.message", async (input, output) => {
const agent = input.agent ?? "unknown"
const startTime = Date.now()
Expand Down Expand Up @@ -332,6 +340,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
info.providerID ?? "unknown",
info.time?.created ?? Date.now(),
ctx,
info.mode,
)
}
await handleMessageUpdated(msgEvt, ctx)
Expand Down
14 changes: 13 additions & 1 deletion src/trace-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { defaultTextMapGetter, ROOT_CONTEXT, trace, type Context } from "@opentelemetry/api"
import {
defaultTextMapGetter,
defaultTextMapSetter,
ROOT_CONTEXT,
trace,
type Context,
type SpanContext,
} from "@opentelemetry/api"
import { W3CTraceContextPropagator } from "@opentelemetry/core"

const propagator = new W3CTraceContextPropagator()
Expand All @@ -11,3 +18,8 @@ export function remoteParentContext(traceparent: string | undefined, tracestate:
const extracted = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter)
return trace.getSpanContext(extracted) ? extracted : undefined
}

/** Injects W3C trace context derived from an explicit span context into HTTP headers. */
export function injectTraceContext(spanContext: SpanContext, headers: Record<string, string>): void {
propagator.inject(trace.setSpanContext(ROOT_CONTEXT, spanContext), headers, defaultTextMapSetter)
}
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ export type PendingRun = {
startTime: number
}

/** Live LLM request span metadata used by the outbound header hook. */
export type LlmRequestContext = {
messageID: string
agent: string
modelID: string
providerID: string
spanContext: SpanContext
}

/** Shared context threaded through every event handler. */
export type HandlerContext = {
log: PluginLogger
Expand All @@ -100,4 +109,6 @@ export type HandlerContext = {
sessionSpanContexts: Map<string, SpanContext>
messageSpans: Map<string, Span>
messageOutputs: Map<string, string>
llmRequestContexts: Map<string, LlmRequestContext[]>
tracePropagationProviders: Set<string>
}
Loading
Loading