From 541c795d5d118e8df963d3d6c4a5c128f5873c0c Mon Sep 17 00:00:00 2001 From: J Date: Wed, 19 Aug 2026 11:39:57 +0900 Subject: [PATCH] feat(chat-headers): propagate the resolved user ID via tracestate Providers that receive W3C trace context now also receive the resolved `user.id` as an extra tracestate member, merged into the tracestate produced by propagation. The injection sits behind the existing OPENCODE_TRACE_PROPAGATION_PROVIDERS gate, so a tracestate is never sent without its traceparent. Two new settings control it: OPENCODE_USER_ID_TRACESTATE_ENABLED (default true) and OPENCODE_USER_ID_TRACESTATE_KEY (default opencode_user_id). Co-Authored-By: Claude Opus 5 --- README.md | 15 ++++++ src/config.ts | 13 ++++++ src/handlers/chat-headers.ts | 33 +++++++++++++- src/index.ts | 5 ++ src/types.ts | 1 + src/user-id.ts | 3 +- tests/chat-headers.test.ts | 88 ++++++++++++++++++++++++++++++++++++ tests/config.test.ts | 12 +++++ 8 files changed, 168 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c8450f6..2afc668 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ The plugin reads settings from `OPENCODE_*` environment variables and inline plu | `OPENCODE_USER_ID_TIMEOUT` | `3000` | User ID request timeout in milliseconds | | `OPENCODE_USER_ID_RETRY_COUNT` | `2` | Retries after the initial user ID request fails, from `0` to `10` | | `OPENCODE_USER_ID_COOLDOWN` | `300000` | Cooldown after all user ID attempts fail; `0` disables the cooldown | +| `OPENCODE_USER_ID_TRACESTATE_ENABLED` | `true` | Adds the resolved `user.id` to the `tracestate` sent to trace propagation providers | +| `OPENCODE_USER_ID_TRACESTATE_KEY` | `opencode_user_id` | `tracestate` key carrying the user ID | ### Plugin options @@ -130,6 +132,8 @@ Option keys mirror the resolved config: | `userIDTimeout` | `OPENCODE_USER_ID_TIMEOUT` | | `userIDRetryCount` | `OPENCODE_USER_ID_RETRY_COUNT` | | `userIDCooldown` | `OPENCODE_USER_ID_COOLDOWN` | +| `userIDTracestateEnabled` | `OPENCODE_USER_ID_TRACESTATE_ENABLED` | +| `userIDTracestateKey` | `OPENCODE_USER_ID_TRACESTATE_KEY` | Keep secrets such as `otlpHeaders` out of committed configuration. Prefer an environment variable or opencode `{env:VAR}` substitution. @@ -174,6 +178,17 @@ export OPENCODE_TRACE_PROPAGATION_PROVIDERS="company-litellm,vllm" Only W3C `traceparent` and `tracestate` are injected. Propagation is disabled when the setting is unset. +### User ID propagation + +Providers that receive W3C trace context also receive the resolved `user.id` as an extra `tracestate` member, merged into the `tracestate` produced by propagation: + +```text +traceparent: 00---01 +tracestate: opencode_user_id=, +``` + +This follows `OPENCODE_TRACE_PROPAGATION_PROVIDERS`: no propagation means no user ID, so a `tracestate` is never sent without its `traceparent`. Nothing is sent while the user ID is still unresolved. Set `OPENCODE_USER_ID_TRACESTATE_ENABLED=false` to keep the user ID out of propagated requests, or `OPENCODE_USER_ID_TRACESTATE_KEY` to change the key. Keys must follow the W3C `tracestate` key syntax: lowercase letters, digits, `_`, `-`, `*`, and `/`. + ## Local development See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/src/config.ts b/src/config.ts index ddd506c..ccf45e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,7 @@ const DEFAULT_SPAN_ATTRIBUTE_COUNT_LIMIT = 4096; const DEFAULT_USER_ID_TIMEOUT = 3000; const DEFAULT_USER_ID_RETRY_COUNT = 2; const DEFAULT_USER_ID_COOLDOWN = 5 * 60 * 1000; +const DEFAULT_USER_ID_TRACESTATE_KEY = "opencode_user_id"; const MAX_USER_ID_RETRY_COUNT = 10; /** Configuration values resolved from `OPENCODE_*` environment variables. */ @@ -16,6 +17,8 @@ export type PluginConfig = { userIDTimeout: number; userIDRetryCount: number; userIDCooldown: number; + userIDTracestateEnabled: boolean; + userIDTracestateKey: string; protocol: "grpc" | "http/protobuf" | "http/json"; tracePrefix: string; otlpHeaders: string | undefined; @@ -68,6 +71,8 @@ export type OtelPluginOptions = { userIDTimeout?: number; userIDRetryCount?: number; userIDCooldown?: number; + userIDTracestateEnabled?: boolean; + userIDTracestateKey?: string; protocol?: "grpc" | "http/protobuf" | "http/json"; tracePrefix?: string; otlpHeaders?: string; @@ -270,6 +275,14 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { "OPENCODE_USER_ID_COOLDOWN", DEFAULT_USER_ID_COOLDOWN ), + userIDTracestateEnabled: + pickBoolean(resolvedOptions.userIDTracestateEnabled) ?? + pickBooleanString(process.env["OPENCODE_USER_ID_TRACESTATE_ENABLED"]) ?? + true, + userIDTracestateKey: + pickString(resolvedOptions.userIDTracestateKey) ?? + process.env["OPENCODE_USER_ID_TRACESTATE_KEY"] ?? + DEFAULT_USER_ID_TRACESTATE_KEY, protocol, spanAttributeCountLimit: pickPositiveInt(resolvedOptions.spanAttributeCountLimit) ?? diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index cd40058..83c5231 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -1,10 +1,40 @@ import type { ProviderContext } from "@opencode-ai/plugin"; import type { Model, UserMessage } from "@opencode-ai/sdk"; +import { createTraceState } from "@opentelemetry/api"; +import { USER_ID } from "@arizeai/openinference-semantic-conventions"; import { LLM_TELEMETRY_REQUEST_HEADER, type HandlerContext } from "../types.ts"; import { injectTraceContext } from "../trace-context.ts"; +import { UNKNOWN_USER_ID } from "../user-id.ts"; import { setBoundedMap } from "../util.ts"; -/** Injects the matching LLM span context for explicitly enabled providers. */ +const VALID_TRACESTATE_VALUE = /^[ -~]{0,255}[!-~]$/; + +function injectUserIDTracestate( + headers: Record, + ctx: HandlerContext +): void { + const key = ctx.userIDTracestateKey; + if (!key) { + return; + } + const userID = ctx.commonAttrs[USER_ID]; + if ( + !userID || + userID === UNKNOWN_USER_ID || + !VALID_TRACESTATE_VALUE.test(userID) || + /[,=]/.test(userID) + ) { + return; + } + headers["tracestate"] = createTraceState(headers["tracestate"]) + .set(key, userID) + .serialize(); +} + +/** + * Injects the matching LLM span context for explicitly enabled providers, along + * with the resolved `user.id` as an extra `tracestate` member. + */ export function handleChatHeaders( input: { sessionID: string; @@ -50,4 +80,5 @@ export function handleChatHeaders( return; } injectTraceContext(request.spanContext, output.headers); + injectUserIDTracestate(output.headers, ctx); } diff --git a/src/index.ts b/src/index.ts index f296e78..6410c34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,8 @@ export const OtelPlugin: Plugin = async ( userIDTimeout: config.userIDTimeout, userIDRetryCount: config.userIDRetryCount, userIDCooldown: config.userIDCooldown, + userIDTracestateEnabled: config.userIDTracestateEnabled, + userIDTracestateKey: config.userIDTracestateKey, }); await log("debug", "config loaded", { @@ -179,6 +181,9 @@ export const OtelPlugin: Plugin = async ( llmRequestContexts, llmTelemetryBindings, tracePropagationProviders: config.tracePropagationProviders, + userIDTracestateKey: config.userIDTracestateEnabled + ? config.userIDTracestateKey + : undefined, activeMessageSpans, llmTelemetryOutputs, }; diff --git a/src/types.ts b/src/types.ts index d70e6a2..4d3d23d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -167,6 +167,7 @@ export type HandlerContext = { llmRequestContexts: Map; llmTelemetryBindings: LlmTelemetryBindings; tracePropagationProviders: Set; + userIDTracestateKey?: string; activeMessageSpans: Map< string, { messageID: string; span: Span; outputEndTime?: number } diff --git a/src/user-id.ts b/src/user-id.ts index 8988641..6ab159f 100644 --- a/src/user-id.ts +++ b/src/user-id.ts @@ -39,7 +39,8 @@ type UserIDManagerConfig = Pick< type UserIDManagerContext = Pick; -const UNKNOWN_USER_ID = "unknown"; +/** Placeholder written to `user.id` while no real user ID has been resolved. */ +export const UNKNOWN_USER_ID = "unknown"; const RETRY_BASE_DELAY_MS = 250; function isResolvedUserID(userID: unknown): userID is string { diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index d8baae9..9c4d20b 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -194,4 +194,92 @@ describe("handleChatHeaders", () => { expect(modelOutput.headers).toEqual({}); expect(providerOutput.headers).toEqual({}); }); + test("injects the resolved user ID as a tracestate member", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u_42" }); + ctx.userIDTracestateKey = "opencode_user_id"; + 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" + ); + expect(output.headers.tracestate).toBe("opencode_user_id=u_42"); + }); + + test("merges the user ID into a propagated tracestate", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u_42" }); + ctx.userIDTracestateKey = "opencode_user_id"; + ctx.tracePropagationProviders.add("company-litellm"); + seedRequest(ctx); + ctx.llmRequestContexts.get("ses_1:user_1")![0]!.spanContext.traceState = + createTraceState("vendor=value"); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBe( + "opencode_user_id=u_42,vendor=value" + ); + }); + + test("honours a custom tracestate key", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u_42" }); + ctx.userIDTracestateKey = "acme_user"; + ctx.tracePropagationProviders.add("company-litellm"); + seedRequest(ctx); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBe("acme_user=u_42"); + }); + + test("does not inject the user ID for a provider without trace propagation", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u_42" }); + ctx.userIDTracestateKey = "opencode_user_id"; + seedRequest(ctx); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBeUndefined(); + }); + + test("does not inject an unresolved user ID", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "unknown" }); + ctx.userIDTracestateKey = "opencode_user_id"; + ctx.tracePropagationProviders.add("company-litellm"); + seedRequest(ctx); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBeUndefined(); + }); + + test("does not inject a user ID with invalid tracestate characters", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u=42,x" }); + ctx.userIDTracestateKey = "opencode_user_id"; + ctx.tracePropagationProviders.add("company-litellm"); + seedRequest(ctx); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBeUndefined(); + }); + + test("does not inject the user ID when the key is unset", () => { + const { ctx } = makeCtx("proj_test", { "user.id": "u_42" }); + ctx.tracePropagationProviders.add("company-litellm"); + seedRequest(ctx); + const output = { headers: {} as Record }; + + handleChatHeaders(makeInput(), output, ctx); + + expect(output.headers.tracestate).toBeUndefined(); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index d599d46..9b11a56 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -26,6 +26,8 @@ const ENV_KEYS = [ "OPENCODE_USER_ID_TIMEOUT", "OPENCODE_USER_ID_RETRY_COUNT", "OPENCODE_USER_ID_COOLDOWN", + "OPENCODE_USER_ID_TRACESTATE_ENABLED", + "OPENCODE_USER_ID_TRACESTATE_KEY", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", ] as const; @@ -168,6 +170,8 @@ describe("loadConfig", () => { expect(cfg.userIDTimeout).toBe(3000); expect(cfg.userIDRetryCount).toBe(2); expect(cfg.userIDCooldown).toBe(300000); + expect(cfg.userIDTracestateEnabled).toBe(true); + expect(cfg.userIDTracestateKey).toBe("opencode_user_id"); }); test("reads user ID environment settings", () => { @@ -178,6 +182,8 @@ describe("loadConfig", () => { process.env["OPENCODE_USER_ID_TIMEOUT"] = "5000"; process.env["OPENCODE_USER_ID_RETRY_COUNT"] = "4"; process.env["OPENCODE_USER_ID_COOLDOWN"] = "0"; + process.env["OPENCODE_USER_ID_TRACESTATE_ENABLED"] = "false"; + process.env["OPENCODE_USER_ID_TRACESTATE_KEY"] = "acme_user"; const cfg = loadConfig(); expect(cfg.userIDEnabled).toBe(false); expect(cfg.userIDEndpoint).toBe("https://identity.example.com/query"); @@ -185,6 +191,8 @@ describe("loadConfig", () => { expect(cfg.userIDTimeout).toBe(5000); expect(cfg.userIDRetryCount).toBe(4); expect(cfg.userIDCooldown).toBe(0); + expect(cfg.userIDTracestateEnabled).toBe(false); + expect(cfg.userIDTracestateKey).toBe("acme_user"); }); test("rejects user ID retry counts above the maximum", () => { @@ -205,6 +213,8 @@ describe("loadConfig", () => { userIDEnabled: false, userIDRetryCount: 0, userIDCooldown: 0, + userIDTracestateEnabled: false, + userIDTracestateKey: "option_user", }); expect(cfg.enabled).toBe(true); expect(cfg.endpoint).toBe("http://from-option:4317"); @@ -215,6 +225,8 @@ describe("loadConfig", () => { expect(cfg.userIDEnabled).toBe(false); expect(cfg.userIDRetryCount).toBe(0); expect(cfg.userIDCooldown).toBe(0); + expect(cfg.userIDTracestateEnabled).toBe(false); + expect(cfg.userIDTracestateKey).toBe("option_user"); }); test("invalid options fall back to environment values", () => {