diff --git a/AGENTS.md b/AGENTS.md index ffca2c4..8e43038 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ src/ - **`setBoundedMap`** — always use this instead of `Map.set` for `pendingToolSpans` and `pendingPermissions` to prevent unbounded growth. - **Single source of truth for tokens/cost** — token and cost counters are incremented only in `message.updated` (`src/handlers/message.ts`), never in `step-finish`. - **Shutdown** — OTel providers are flushed via `SIGTERM`/`SIGINT`/`beforeExit`. Do not use `process.on("exit")` for async flushing. -- **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`. Never use bare `OTEL_*` names for plugin config. `loadConfig` copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes. +- **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_DISABLE_USER_TRACKING`. Never use bare `OTEL_*` names for plugin config. `loadConfig` copies `OPENCODE_OTLP_HEADERS` → `OTEL_EXPORTER_OTLP_HEADERS` and `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes. - **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var. The plugin always loads regardless; only telemetry is disabled when unset. - **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility. diff --git a/README.md b/README.md index e4d3eb3..797255b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,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) + - [User identity tracking](#user-identity-tracking) - [Disabling specific metrics](#disabling-specific-metrics) - [Datadog example](#datadog-example) - [Honeycomb example](#honeycomb-example) @@ -93,6 +94,7 @@ All configuration is via environment variables. Set them in your shell profile ( | `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`. | | `OPENCODE_RESOURCE_ATTRIBUTES` | _(unset)_ | Comma-separated `key=value` pairs merged into the OTel resource. Example: `service.version=1.2.3,deployment.environment=production` | | `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_DISABLE_USER_TRACKING` | _(unset)_ | Set to any non-empty value to omit `enduser.id` from all signals and the resource. See [User identity tracking](#user-identity-tracking). | ### Quick start @@ -142,6 +144,33 @@ 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. +### User identity tracking + +The plugin tags every metric datapoint, log record, and trace span with an `enduser.id` attribute identifying the developer running the session. The value is auto-detected from `os.userInfo().username`; if opencode's config sets a custom `username`, that value supersedes the OS one for metrics and logs once the config hook fires. + +The reason this is a signal-level attribute (not just a resource attribute set via `OPENCODE_RESOURCE_ATTRIBUTES`) is that some OTLP backends — Datadog's direct OTLP intake in particular — only promote a hardcoded set of well-known resource attributes to tags. Custom resource attributes like `enduser.id` are silently dropped. Datapoint-level attributes are always preserved. + +| Signal | Where `enduser.id` lives | +|--------|--------------------------| +| Metrics | Datapoint attribute (refined by `cfg.username` after config hook) | +| Logs | Log record attribute (refined by `cfg.username` after config hook) | +| Traces | Resource attribute (frozen at startup) **and** span attribute (refined by `cfg.username` for spans started after the config hook fires) | + +Override precedence: + +- `OPENCODE_RESOURCE_ATTRIBUTES=enduser.id=…` overrides the resource-level value (used by trace spans and, depending on the backend, attached to metric/log records). It does not affect span attributes set per-span from the auto-detected username. +- The trace resource is built once at startup and does not refresh when `cfg.username` arrives later; new span attributes do reflect that refinement. In practice the OS and configured usernames are identical for almost every user. + +If `os.userInfo()` throws (e.g. containerised runs with no passwd entry), `enduser.id` is omitted from every signal rather than tagged with a placeholder. + +To opt out entirely: + +```bash +export OPENCODE_DISABLE_USER_TRACKING=1 +``` + +When set, `enduser.id` is omitted from `commonAttrs` and from the auto-detected resource. If `OPENCODE_RESOURCE_ATTRIBUTES=enduser.id=…` is set in addition, the explicit value still lands on the resource (the env merge runs unconditionally). + ### 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 1a3ec07..7d83d16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ export type PluginConfig = { metricsTemporality: MetricsTemporality | undefined disabledMetrics: Set disabledTraces: Set + disableUserTracking: boolean } /** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */ @@ -88,6 +89,7 @@ export function loadConfig(): PluginConfig { metricsTemporality, disabledMetrics, disabledTraces, + disableUserTracking: !!process.env["OPENCODE_DISABLE_USER_TRACKING"], } } diff --git a/src/index.ts b/src/index.ts index f643160..6b5f308 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,10 +16,11 @@ import type { EventSessionDiff, EventCommandExecuted, } from "@opencode-ai/sdk" -import { LEVELS, type Level, type HandlerContext } from "./types.ts" +import { LEVELS, type Level, type HandlerContext, type MutableCommonAttrs } from "./types.ts" import { loadConfig, resolveHelperPath, resolveLogLevel } from "./config.ts" import { probeEndpoint } from "./probe.ts" import { setupOtel, createInstruments } from "./otel.ts" +import { safeUsername } from "./util.ts" import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "./handlers/session.ts" import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts" import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts" @@ -73,6 +74,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree }) } + const endUserId = config.disableUserTracking ? undefined : safeUsername() + const { meterProvider, loggerProvider, tracerProvider } = await setupOtel( config.endpoint, config.protocol, @@ -81,6 +84,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree PLUGIN_VERSION, config.otlpHeaders, otlpHeadersHelper, + endUserId, ) await log("info", "OTel SDK initialized") @@ -95,7 +99,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree const sessionInputs = new Map() const messageOutputs = new Map() const { disabledMetrics, disabledTraces } = config - const commonAttrs = { "project.id": project.id } as const + const commonAttrs: MutableCommonAttrs = { "project.id": project.id } + if (endUserId) commonAttrs["enduser.id"] = endUserId if (disabledMetrics.size > 0) { await log("info", "metrics disabled", { disabled: [...disabledMetrics] }) @@ -157,6 +162,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree await log("warn", `unknown log level "${cfg.logLevel}", keeping "${minLevel}"`) } } + const trimmedUsername = cfg.username?.trim() + if (!config.disableUserTracking && trimmedUsername) { + commonAttrs["enduser.id"] = trimmedUsername + } }, "chat.message": safe("chat.message", async (input, output) => { diff --git a/src/otel.ts b/src/otel.ts index bac3e3e..a953445 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -25,16 +25,18 @@ import { /** * Builds an OTel `Resource` seeded with `service.name`, `app.version`, `os.type`, and - * `host.arch`. Additional attributes from `OTEL_RESOURCE_ATTRIBUTES` are merged in and - * may override the defaults. + * `host.arch`. When `endUserId` is provided, `enduser.id` is seeded too. Additional + * attributes from `OTEL_RESOURCE_ATTRIBUTES` are merged in last and may override the + * defaults (including `enduser.id`). */ -export function buildResource(version: string) { +export function buildResource(version: string, endUserId?: string) { const attrs: Record = { [ATTR_SERVICE_NAME]: "opencode", "app.version": version, "os.type": process.platform, [ATTR_HOST_ARCH]: process.arch, } + if (endUserId) attrs["enduser.id"] = endUserId const raw = process.env["OTEL_RESOURCE_ATTRIBUTES"] if (raw) { for (const pair of raw.split(",")) { @@ -76,8 +78,9 @@ export async function setupOtel( version: string, otlpHeaders?: string, otlpHeadersHelper?: string, + endUserId?: string, ): Promise { - const resource = buildResource(version) + const resource = buildResource(version, endUserId) const staticHeaders = parseOtlpHeaders(otlpHeaders) const dynamicHeaders = new DynamicHeaders(staticHeaders, otlpHeadersHelper) if (otlpHeadersHelper) { diff --git a/src/types.ts b/src/types.ts index 0132998..88d0b24 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,10 @@ export type PluginLogger = ( ) => Promise /** OTel resource attributes common to every emitted log and metric. */ -export type CommonAttrs = { readonly "project.id": string } +export type CommonAttrs = { readonly "project.id": string; readonly "enduser.id"?: string } + +/** Writable variant of `CommonAttrs`. */ +export type MutableCommonAttrs = { -readonly [K in keyof CommonAttrs]: CommonAttrs[K] } /** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */ export type PendingToolSpan = { diff --git a/src/util.ts b/src/util.ts index 32548bc..415fd0f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,19 @@ +import os from "node:os" import { MAX_PENDING } from "./types.ts" import type { HandlerContext } from "./types.ts" +/** + * Returns the OS-level username, or `undefined` when `os.userInfo()` throws + * (e.g. containerised runs with no matching passwd entry). + */ +export function safeUsername(): string | undefined { + try { + return os.userInfo().username + } catch { + return undefined + } +} + /** Returns a human-readable summary string from an opencode error object. */ export function errorSummary(err: { name: string; data?: unknown } | undefined): string { if (!err) return "unknown" diff --git a/tests/config.test.ts b/tests/config.test.ts index f7e096e..7a59a12 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -53,6 +53,7 @@ describe("loadConfig", () => { "OPENCODE_OTLP_METRICS_TEMPORALITY", "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_TRACES", + "OPENCODE_DISABLE_USER_TRACKING", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", @@ -259,6 +260,20 @@ describe("loadConfig", () => { expect(disabledTraces.has("unknown_type")).toBe(true) expect(disabledTraces.size).toBe(2) }) + + test("disableUserTracking defaults to false", () => { + expect(loadConfig().disableUserTracking).toBe(false) + }) + + test("disableUserTracking is true when OPENCODE_DISABLE_USER_TRACKING is set", () => { + process.env["OPENCODE_DISABLE_USER_TRACKING"] = "1" + expect(loadConfig().disableUserTracking).toBe(true) + }) + + test("disableUserTracking accepts any non-empty value", () => { + process.env["OPENCODE_DISABLE_USER_TRACKING"] = "true" + expect(loadConfig().disableUserTracking).toBe(true) + }) }) describe("resolveLogLevel", () => { diff --git a/tests/otel.test.ts b/tests/otel.test.ts index ab638f5..b667220 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -40,4 +40,22 @@ describe("buildResource", () => { const resource = buildResource("0.0.1") expect(resource.attributes["service.name"]).toBe("my-override") }) + + test("includes enduser.id when endUserId is provided", () => { + delete process.env["OTEL_RESOURCE_ATTRIBUTES"] + const resource = buildResource("0.0.1", "alice") + expect(resource.attributes["enduser.id"]).toBe("alice") + }) + + test("omits enduser.id when endUserId is undefined", () => { + delete process.env["OTEL_RESOURCE_ATTRIBUTES"] + const resource = buildResource("0.0.1") + expect(resource.attributes["enduser.id"]).toBeUndefined() + }) + + test("OTEL_RESOURCE_ATTRIBUTES enduser.id overrides endUserId argument", () => { + process.env["OTEL_RESOURCE_ATTRIBUTES"] = "enduser.id=override" + const resource = buildResource("0.0.1", "alice") + expect(resource.attributes["enduser.id"]).toBe("override") + }) }) diff --git a/tests/util.test.ts b/tests/util.test.ts index 72c0103..601e74d 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,7 +1,27 @@ -import { describe, test, expect } from "bun:test" -import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../src/util.ts" +import { describe, test, expect, mock, afterEach } from "bun:test" +import os from "node:os" +import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled, safeUsername } from "../src/util.ts" import { MAX_PENDING } from "../src/types.ts" +describe("safeUsername", () => { + const originalUserInfo = os.userInfo + afterEach(() => { + os.userInfo = originalUserInfo + }) + + test("returns the OS username on success", () => { + os.userInfo = mock( + () => ({ username: "alice" } as unknown as ReturnType), + ) as typeof os.userInfo + expect(safeUsername()).toBe("alice") + }) + + test("returns undefined when os.userInfo() throws", () => { + os.userInfo = mock(() => { throw new Error("no passwd entry") }) as typeof os.userInfo + expect(safeUsername()).toBeUndefined() + }) +}) + describe("errorSummary", () => { test("returns 'unknown' for undefined", () => { expect(errorSummary(undefined)).toBe("unknown")