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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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-<trace id>-<span id>-01
tracestate: opencode_user_id=<user id>,<other members>
```

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).
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) ??
Expand Down
33 changes: 32 additions & 1 deletion src/handlers/chat-headers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
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;
Expand Down Expand Up @@ -50,4 +80,5 @@ export function handleChatHeaders(
return;
}
injectTraceContext(request.spanContext, output.headers);
injectUserIDTracestate(output.headers, ctx);
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -179,6 +181,9 @@ export const OtelPlugin: Plugin = async (
llmRequestContexts,
llmTelemetryBindings,
tracePropagationProviders: config.tracePropagationProviders,
userIDTracestateKey: config.userIDTracestateEnabled
? config.userIDTracestateKey
: undefined,
activeMessageSpans,
llmTelemetryOutputs,
};
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export type HandlerContext = {
llmRequestContexts: Map<string, LlmRequestContext[]>;
llmTelemetryBindings: LlmTelemetryBindings;
tracePropagationProviders: Set<string>;
userIDTracestateKey?: string;
activeMessageSpans: Map<
string,
{ messageID: string; span: Span; outputEndTime?: number }
Expand Down
3 changes: 2 additions & 1 deletion src/user-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ type UserIDManagerConfig = Pick<

type UserIDManagerContext = Pick<HandlerContext, "commonAttrs" | "log">;

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 {
Expand Down
88 changes: 88 additions & 0 deletions tests/chat-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> };

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<string, string> };

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<string, string> };

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<string, string> };

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<string, string> };

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<string, string> };

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<string, string> };

handleChatHeaders(makeInput(), output, ctx);

expect(output.headers.tracestate).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand All @@ -178,13 +182,17 @@ 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");
expect(cfg.userIDAuthHeader).toBe("secret");
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", () => {
Expand All @@ -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");
Expand All @@ -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", () => {
Expand Down
Loading