From 060e403f5c89e7d2b90dc93389f01651fc938637 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Thu, 23 Jul 2026 16:16:48 -0400 Subject: [PATCH 1/4] feat(tracing): propagate context to LLM providers --- README.md | 15 +++++ src/config.ts | 10 ++++ src/handlers/chat-headers.ts | 24 ++++++++ src/handlers/message.ts | 12 ++++ src/handlers/session.ts | 3 + src/index.ts | 9 +++ src/trace-context.ts | 14 ++++- src/types.ts | 11 ++++ tests/chat-headers.test.ts | 110 +++++++++++++++++++++++++++++++++++ tests/config.test.ts | 19 ++++++ tests/handlers/spans.test.ts | 20 ++++++- tests/helpers.ts | 2 + 12 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/handlers/chat-headers.ts create mode 100644 tests/chat-headers.test.ts diff --git a/README.md b/README.md index 1e75f8f..a396d7d 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) @@ -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. @@ -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). diff --git a/src/config.ts b/src/config.ts index 9be26f7..a9fd349 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ export type PluginConfig = { metricsTemporality: MetricsTemporality | undefined disabledMetrics: Set disabledTraces: Set + tracePropagationProviders: Set } export function parseAttributePairs(raw: string | undefined): Record { @@ -69,6 +70,7 @@ export type OtelPluginOptions = { metricsTemporality?: MetricsTemporality disabledMetrics?: string[] disabledTraces?: string[] + tracePropagationProviders?: string[] } const VALID_PROTOCOLS = new Set(["grpc", "http/protobuf", "http/json"]) @@ -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"), @@ -199,6 +208,7 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { metricsTemporality, disabledMetrics, disabledTraces, + tracePropagationProviders, } } diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts new file mode 100644 index 0000000..6bddbc1 --- /dev/null +++ b/src/handlers/chat-headers.ts @@ -0,0 +1,24 @@ +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 }, + 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}`) + if (!request) return + if ( + request.agent !== input.agent + || request.modelID !== input.model.id + || request.providerID !== providerID + ) return + + injectTraceContext(request.spanContext, output.headers) +} diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 7ccdf4d..375fe8f 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -151,6 +151,10 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext ctx.messageSpans.delete(msgKey) ctx.messageOutputs.delete(msgKey) } + const requestKey = `${sessionID}:${assistant.parentID}` + if (ctx.llmRequestContexts.get(requestKey)?.messageID === assistant.id) { + ctx.llmRequestContexts.delete(requestKey) + } if (assistant.error) { ctx.emitLog({ @@ -424,6 +428,7 @@ export function startMessageSpan( providerID: string, startTime: number, ctx: HandlerContext, + agent?: string, ) { if (!isTraceEnabled("llm", ctx)) return const msgKey = `${sessionID}:${messageID}` @@ -459,4 +464,11 @@ export function startMessageSpan( resolveSessionTraceContext(sessionID, ctx, { runID: parentID, assistantMessageID: messageID }), ) setBoundedMap(ctx.messageSpans, msgKey, msgSpan) + setBoundedMap(ctx.llmRequestContexts, `${sessionID}:${parentID}`, { + messageID, + agent: agent ?? agentName, + modelID, + providerID, + spanContext: msgSpan.spanContext(), + }) } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index b03a14c..9a952dc 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -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. */ diff --git a/src/index.ts b/src/index.ts index 75bc279..55ae91b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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" @@ -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), @@ -157,6 +159,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree sessionSpanContexts, messageSpans, messageOutputs, + llmRequestContexts, + tracePropagationProviders: config.tracePropagationProviders, } let shuttingDown = false @@ -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() @@ -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) diff --git a/src/trace-context.ts b/src/trace-context.ts index 1036169..ff6b08b 100644 --- a/src/trace-context.ts +++ b/src/trace-context.ts @@ -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() @@ -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): void { + propagator.inject(trace.setSpanContext(ROOT_CONTEXT, spanContext), headers, defaultTextMapSetter) +} diff --git a/src/types.ts b/src/types.ts index 2aa33cb..0656421 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 @@ -100,4 +109,6 @@ export type HandlerContext = { sessionSpanContexts: Map messageSpans: Map messageOutputs: Map + llmRequestContexts: Map + tracePropagationProviders: Set } diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts new file mode 100644 index 0000000..30a6e42 --- /dev/null +++ b/tests/chat-headers.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import { createTraceState } from "@opentelemetry/api" +import { handleChatHeaders } from "../src/handlers/chat-headers.ts" +import { makeCtx } from "./helpers.ts" + +function makeInput(overrides: { agent?: string; modelID?: string; providerID?: string; messageID?: string } = {}) { + const providerID = overrides.providerID ?? "company-litellm" + return { + sessionID: "ses_1", + agent: overrides.agent ?? "build", + model: { id: overrides.modelID ?? "claude", providerID } as any, + provider: { source: "config", info: { id: providerID }, options: {} } as any, + message: { id: overrides.messageID ?? "user_1" } as any, + } +} + +function seedRequest(ctx: ReturnType["ctx"], overrides: { agent?: string; modelID?: string; providerID?: string } = {}) { + ctx.llmRequestContexts.set("ses_1:user_1", { + messageID: "msg_1", + agent: overrides.agent ?? "build", + modelID: overrides.modelID ?? "claude", + providerID: overrides.providerID ?? "company-litellm", + spanContext: { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }, + }) +} + +describe("handleChatHeaders", () => { + test("injects traceparent for an enabled provider", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("company-litellm") + seedRequest(ctx) + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers.traceparent).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + }) + + test("injects tracestate when present", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("company-litellm") + seedRequest(ctx) + ctx.llmRequestContexts.get("ses_1:user_1")!.spanContext.traceState = createTraceState("vendor=value") + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers.tracestate).toBe("vendor=value") + }) + + test("supports an explicit wildcard", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("*") + seedRequest(ctx) + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers.traceparent).toBeDefined() + }) + + test("does not inject for an unconfigured provider", () => { + const { ctx } = makeCtx() + seedRequest(ctx) + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers).toEqual({}) + }) + + test("does not inject without a matching live request", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("company-litellm") + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers).toEqual({}) + }) + + test("does not attach a concurrent title request to the main span", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("company-litellm") + seedRequest(ctx) + const output = { headers: {} as Record } + + handleChatHeaders(makeInput({ agent: "title" }), output, ctx) + + expect(output.headers).toEqual({}) + }) + + test("requires matching model and provider metadata", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("*") + seedRequest(ctx) + const modelOutput = { headers: {} as Record } + const providerOutput = { headers: {} as Record } + + handleChatHeaders(makeInput({ modelID: "other" }), modelOutput, ctx) + handleChatHeaders(makeInput({ providerID: "other" }), providerOutput, ctx) + + expect(modelOutput.headers).toEqual({}) + expect(providerOutput.headers).toEqual({}) + }) +}) diff --git a/tests/config.test.ts b/tests/config.test.ts index deb96fe..8910138 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_TRACE_PROPAGATION_PROVIDERS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -325,6 +326,15 @@ describe("loadConfig", () => { process.env["OPENCODE_DISABLE_TRACES"] = "1" expect(loadConfig().disabledTraces).toEqual(new Set(TRACE_TYPES)) }) + + test("tracePropagationProviders is empty when unset", () => { + expect(loadConfig().tracePropagationProviders).toEqual(new Set()) + }) + + test("parses trace propagation providers", () => { + process.env["OPENCODE_TRACE_PROPAGATION_PROVIDERS"] = " company-litellm , vllm " + expect(loadConfig().tracePropagationProviders).toEqual(new Set(["company-litellm", "vllm"])) + }) }) describe("loadConfig options", () => { @@ -341,6 +351,7 @@ describe("loadConfig options", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_TRACE_PROPAGATION_PROVIDERS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -452,6 +463,14 @@ describe("loadConfig options", () => { expect(disabledTraces).toEqual(new Set(["llm", "tool"])) }) + test("option tracePropagationProviders overrides the env var", () => { + process.env["OPENCODE_TRACE_PROPAGATION_PROVIDERS"] = "env-provider" + const { tracePropagationProviders } = loadConfig({ + tracePropagationProviders: [" company-litellm ", "vllm"], + }) + expect(tracePropagationProviders).toEqual(new Set(["company-litellm", "vllm"])) + }) + test("env values still apply when no options are passed", () => { process.env["OPENCODE_ENABLE_TELEMETRY"] = "1" process.env["OPENCODE_OTLP_ENDPOINT"] = "http://env:4317" diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index b7f52bf..775334f 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -348,10 +348,16 @@ describe("tool spans", () => { describe("message (LLM) spans", () => { test("startMessageSpan creates an llm span", () => { const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) + startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx, "build") expect(tracer.spans).toHaveLength(1) expect(tracer.spans[0]!.name).toBe("opencode.llm") expect(ctx.messageSpans.has("ses_1:msg_1")).toBe(true) + expect(ctx.llmRequestContexts.get("ses_1:user_1")).toMatchObject({ + messageID: "msg_1", + agent: "build", + modelID: "claude-3-5-sonnet", + providerID: "anthropic", + }) }) test("startMessageSpan sets OpenInference LLM attributes", () => { @@ -379,6 +385,17 @@ describe("message (LLM) spans", () => { expect(span.ended).toBe(true) expect(span.endTime).toBe(2000) expect(ctx.messageSpans.has("ses_1:msg_1")).toBe(false) + expect(ctx.llmRequestContexts.has("ses_1:user_1")).toBe(false) + }) + + test("an older completion does not remove a newer request context", () => { + const { ctx } = makeCtx() + startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) + startMessageSpan("ses_1", "msg_2", "user_1", "claude", "anthropic", 1500, ctx) + + handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", time: { created: 1000, completed: 2000 } }), ctx) + + expect(ctx.llmRequestContexts.get("ses_1:user_1")?.messageID).toBe("msg_2") }) test("handleMessageUpdated sets OK status on success", () => { @@ -476,6 +493,7 @@ describe("orphaned span cleanup", () => { startMessageSpan("ses_1", "msg_orphan", "user_1", "claude", "anthropic", 1000, ctx) handleSessionIdle(makeSessionIdle("ses_1"), ctx) expect(ctx.messageSpans.has("ses_1:msg_orphan")).toBe(false) + expect(ctx.llmRequestContexts.has("ses_1:user_1")).toBe(false) const msgSpan = tracer.spans.find(s => s.name === "opencode.llm")! expect(msgSpan.ended).toBe(true) expect(msgSpan.status.code).toBe(SpanStatusCode.ERROR) diff --git a/tests/helpers.ts b/tests/helpers.ts index 14d2bff..a1594b9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -242,6 +242,8 @@ export function makeCtx( sessionSpanContexts: new Map(), messageSpans: new Map(), messageOutputs: new Map(), + llmRequestContexts: new Map(), + tracePropagationProviders: new Set(), } return { From d755fe30c6c5afb7e36c03a8e37c1ba2b62c30b3 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Thu, 23 Jul 2026 17:55:38 -0400 Subject: [PATCH 2/4] fix(tracing): preserve concurrent LLM contexts --- src/handlers/chat-headers.ts | 11 ++++---- src/handlers/message.ts | 23 ++++++++++------ src/types.ts | 2 +- tests/chat-headers.test.ts | 53 ++++++++++++++++++++++++++++-------- tests/handlers/spans.test.ts | 4 +-- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index 6bddbc1..aae5f4b 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -12,13 +12,12 @@ export function handleChatHeaders( const providerID = input.model.providerID if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return - const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`) + const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`)?.find(candidate => + candidate.agent === input.agent + && candidate.modelID === input.model.id + && candidate.providerID === providerID + ) if (!request) return - if ( - request.agent !== input.agent - || request.modelID !== input.model.id - || request.providerID !== providerID - ) return injectTraceContext(request.spanContext, output.headers) } diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 375fe8f..248228a 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -152,7 +152,10 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext ctx.messageOutputs.delete(msgKey) } const requestKey = `${sessionID}:${assistant.parentID}` - if (ctx.llmRequestContexts.get(requestKey)?.messageID === assistant.id) { + 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) } @@ -464,11 +467,15 @@ export function startMessageSpan( resolveSessionTraceContext(sessionID, ctx, { runID: parentID, assistantMessageID: messageID }), ) setBoundedMap(ctx.messageSpans, msgKey, msgSpan) - setBoundedMap(ctx.llmRequestContexts, `${sessionID}:${parentID}`, { - messageID, - agent: agent ?? agentName, - modelID, - providerID, - spanContext: msgSpan.spanContext(), - }) + const requestKey = `${sessionID}:${parentID}` + setBoundedMap(ctx.llmRequestContexts, requestKey, [ + ...(ctx.llmRequestContexts.get(requestKey) ?? []), + { + messageID, + agent: agent ?? agentName, + modelID, + providerID, + spanContext: msgSpan.spanContext(), + }, + ]) } diff --git a/src/types.ts b/src/types.ts index 0656421..da0e816 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,6 @@ export type HandlerContext = { sessionSpanContexts: Map messageSpans: Map messageOutputs: Map - llmRequestContexts: Map + llmRequestContexts: Map tracePropagationProviders: Set } diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 30a6e42..433240c 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -15,17 +15,19 @@ function makeInput(overrides: { agent?: string; modelID?: string; providerID?: s } function seedRequest(ctx: ReturnType["ctx"], overrides: { agent?: string; modelID?: string; providerID?: string } = {}) { - ctx.llmRequestContexts.set("ses_1:user_1", { - messageID: "msg_1", - agent: overrides.agent ?? "build", - modelID: overrides.modelID ?? "claude", - providerID: overrides.providerID ?? "company-litellm", - spanContext: { - traceId: "0af7651916cd43dd8448eb211c80319c", - spanId: "b7ad6b7169203331", - traceFlags: 1, + ctx.llmRequestContexts.set("ses_1:user_1", [ + { + messageID: "msg_1", + agent: overrides.agent ?? "build", + modelID: overrides.modelID ?? "claude", + providerID: overrides.providerID ?? "company-litellm", + spanContext: { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }, }, - }) + ]) } describe("handleChatHeaders", () => { @@ -44,7 +46,7 @@ describe("handleChatHeaders", () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("company-litellm") seedRequest(ctx) - ctx.llmRequestContexts.get("ses_1:user_1")!.spanContext.traceState = createTraceState("vendor=value") + ctx.llmRequestContexts.get("ses_1:user_1")![0]!.spanContext.traceState = createTraceState("vendor=value") const output = { headers: {} as Record } handleChatHeaders(makeInput(), output, ctx) @@ -94,6 +96,35 @@ describe("handleChatHeaders", () => { expect(output.headers).toEqual({}) }) + test("selects each concurrently live request by agent, model, and provider", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("*") + seedRequest(ctx) + ctx.llmRequestContexts.get("ses_1:user_1")!.push({ + messageID: "msg_2", + agent: "review", + modelID: "gpt-5", + providerID: "company-openai", + spanContext: { + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + traceFlags: 1, + }, + }) + const buildOutput = { headers: {} as Record } + const reviewOutput = { headers: {} as Record } + + handleChatHeaders(makeInput(), buildOutput, ctx) + handleChatHeaders( + makeInput({ agent: "review", modelID: "gpt-5", providerID: "company-openai" }), + reviewOutput, + ctx, + ) + + expect(buildOutput.headers.traceparent).toContain("0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331") + expect(reviewOutput.headers.traceparent).toContain("4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7") + }) + test("requires matching model and provider metadata", () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("*") diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 775334f..18851d0 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -352,7 +352,7 @@ describe("message (LLM) spans", () => { expect(tracer.spans).toHaveLength(1) expect(tracer.spans[0]!.name).toBe("opencode.llm") expect(ctx.messageSpans.has("ses_1:msg_1")).toBe(true) - expect(ctx.llmRequestContexts.get("ses_1:user_1")).toMatchObject({ + expect(ctx.llmRequestContexts.get("ses_1:user_1")?.[0]).toMatchObject({ messageID: "msg_1", agent: "build", modelID: "claude-3-5-sonnet", @@ -395,7 +395,7 @@ describe("message (LLM) spans", () => { handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", time: { created: 1000, completed: 2000 } }), ctx) - expect(ctx.llmRequestContexts.get("ses_1:user_1")?.messageID).toBe("msg_2") + expect(ctx.llmRequestContexts.get("ses_1:user_1")?.map(request => request.messageID)).toEqual(["msg_2"]) }) test("handleMessageUpdated sets OK status on success", () => { From 0af275586cb50631419a1c3f122acb07993193e5 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Thu, 23 Jul 2026 18:35:30 -0400 Subject: [PATCH 3/4] fix(ci): align Discord workflow permissions --- .github/workflows/discord-notify.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/discord-notify.yml b/.github/workflows/discord-notify.yml index 0f0f2a9..262a07b 100644 --- a/.github/workflows/discord-notify.yml +++ b/.github/workflows/discord-notify.yml @@ -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 From ba7a3af2556a35e1b7f2287efa994b05467ccd56 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Thu, 23 Jul 2026 18:37:33 -0400 Subject: [PATCH 4/4] fix(tracing): prefer current LLM request context --- src/handlers/chat-headers.ts | 2 +- tests/chat-headers.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index aae5f4b..626c7e5 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -12,7 +12,7 @@ export function handleChatHeaders( const providerID = input.model.providerID if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return - const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`)?.find(candidate => + const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`)?.findLast(candidate => candidate.agent === input.agent && candidate.modelID === input.model.id && candidate.providerID === providerID diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 433240c..024b1c6 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -125,6 +125,28 @@ describe("handleChatHeaders", () => { expect(reviewOutput.headers.traceparent).toContain("4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7") }) + test("selects the newest live request when metadata is identical", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("company-litellm") + seedRequest(ctx) + ctx.llmRequestContexts.get("ses_1:user_1")!.push({ + messageID: "msg_2", + agent: "build", + modelID: "claude", + providerID: "company-litellm", + spanContext: { + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + traceFlags: 1, + }, + }) + const output = { headers: {} as Record } + + handleChatHeaders(makeInput(), output, ctx) + + expect(output.headers.traceparent).toBe("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + }) + test("requires matching model and provider metadata", () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("*")