Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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")
})
})
23 changes: 21 additions & 2 deletions tests/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
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", () => {
const u = safeUsername()
expect(typeof u).toBe("string")
expect((u as string).length).toBeGreaterThan(0)
})
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