Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand Down Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type PluginConfig = {
metricsTemporality: MetricsTemporality | undefined
disabledMetrics: Set<string>
disabledTraces: Set<string>
disableUserTracking: boolean
}

/** Parses a positive integer from an environment variable, returning `fallback` if absent or invalid. */
Expand Down Expand Up @@ -88,6 +89,7 @@ export function loadConfig(): PluginConfig {
metricsTemporality,
disabledMetrics,
disabledTraces,
disableUserTracking: !!process.env["OPENCODE_DISABLE_USER_TRACKING"],
}
}

Expand Down
13 changes: 11 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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")

Expand All @@ -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] })
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 7 additions & 4 deletions src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
[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(",")) {
Expand Down Expand Up @@ -76,8 +78,9 @@ export async function setupOtel(
version: string,
otlpHeaders?: string,
otlpHeadersHelper?: string,
endUserId?: string,
): Promise<OtelProviders> {
const resource = buildResource(version)
const resource = buildResource(version, endUserId)
const staticHeaders = parseOtlpHeaders(otlpHeaders)
const dynamicHeaders = new DynamicHeaders(staticHeaders, otlpHeadersHelper)
if (otlpHeadersHelper) {
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export type PluginLogger = (
) => Promise<void>

/** 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 = {
Expand Down
13 changes: 13 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 15 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 18 additions & 0 deletions tests/otel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
24 changes: 22 additions & 2 deletions tests/util.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof os.userInfo>),
) as typeof os.userInfo
expect(safeUsername()).toBe("alice")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
Expand Down
Loading