From 063da94e197d2578b02a0e378e3fc4b73ed66f63 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 15:33:43 -0400 Subject: [PATCH 01/13] feat!: migrate to OpenCode V2 The plugin now targets OpenCode >=2 only and default-exports a V2 plugin definition (id: "devtheops.otel", setup). OpenCode V1 is maintained on the v1 branch and the 1.x release line. - Replace V1 event hooks with ctx.event.subscribe() over the V2 taxonomy (session.execution.*, session.step.*, session.tool.*, session.usage.updated, session.retry.scheduled) - Register session.hook("prompt") and session.hook("model.request") - Share the OTel providers and tracing state per process; dedupe by event id - Emit per-step LLM spans and per-tool spans with explicit start/end timings - Lazily initialize and count sessions whose session.created was not replayed - Drop lines_of_code metrics (no V2 session diff) and command.executed - Replace @opencode-ai/plugin + @opencode-ai/sdk with type-only @opencode/plugin BREAKING CHANGE: the opencode config key is now "plugins" and the package supports OpenCode >=2 only. Use the 1.x line / v1 branch for OpenCode V1. --- AGENTS.md | 54 +- CONTRIBUTING.md | 18 +- README.md | 80 ++- bun.lock | 551 +++++++++++++++++++- package.json | 11 +- src/config.ts | 3 + src/handlers/activity.ts | 87 ---- src/handlers/chat-headers.ts | 33 +- src/handlers/message.ts | 481 ------------------ src/handlers/permission.ts | 47 +- src/handlers/session.ts | 459 +++++++++-------- src/handlers/step.ts | 279 ++++++++++ src/handlers/tool.ts | 197 +++++++ src/index.ts | 371 +------------- src/otel.ts | 16 +- src/plugin.ts | 235 +++++++++ src/state.ts | 103 ++++ src/types.ts | 125 +++-- src/util.ts | 134 +++-- tests/chat-headers.test.ts | 172 +------ tests/handlers/activity.test.ts | 184 ------- tests/handlers/disabled-metrics.test.ts | 271 ---------- tests/handlers/disabled-signals.test.ts | 113 ---- tests/handlers/disabled.test.ts | 59 +++ tests/handlers/message.test.ts | 455 ----------------- tests/handlers/permission.test.ts | 87 +--- tests/handlers/session.test.ts | 348 +++++-------- tests/handlers/spans.test.ts | 650 +----------------------- tests/handlers/step.test.ts | 84 +++ tests/handlers/tool.test.ts | 77 +++ tests/helpers.ts | 84 +-- tests/util.test.ts | 156 ++---- 32 files changed, 2354 insertions(+), 3670 deletions(-) delete mode 100644 src/handlers/activity.ts delete mode 100644 src/handlers/message.ts create mode 100644 src/handlers/step.ts create mode 100644 src/handlers/tool.ts create mode 100644 src/plugin.ts create mode 100644 src/state.ts delete mode 100644 tests/handlers/activity.test.ts delete mode 100644 tests/handlers/disabled-metrics.test.ts delete mode 100644 tests/handlers/disabled-signals.test.ts create mode 100644 tests/handlers/disabled.test.ts delete mode 100644 tests/handlers/message.test.ts create mode 100644 tests/handlers/step.test.ts create mode 100644 tests/handlers/tool.test.ts diff --git a/AGENTS.md b/AGENTS.md index dce4d97..321ca24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Always run after making changes: bun run typecheck ``` -There is no build step. TypeScript source files are published directly and loaded natively by Bun. +There is no build step during development. TypeScript source files are published directly and loaded natively by Bun. ## Testing @@ -18,21 +18,32 @@ There is no build step. TypeScript source files are published directly and loade bun test ``` +## Target runtime + +`main` targets **OpenCode V2** (`>=2`) and exports a `Plugin.define`-style default export +(`id: "devtheops.otel"`, `setup`). The OpenCode V1 plugin lives on the `v1` branch and is +released from the `1.x` line; do not add V1 compatibility shims to `main`. + ## Project layout ```text src/ -├── index.ts — Plugin entrypoint -├── types.ts — Shared types -├── config.ts — Env config and log level +├── index.ts — V2 plugin default export (id + setup) +├── plugin.ts — setup(): config, hooks, event subscription, dispatch +├── state.ts — per-process shared OTel SDK + tracing state (globalThis) +├── types.ts — Shared types (OpenCodeEvent, HandlerContext, TracingState, etc.) +├── config.ts — Env/option config loading and log level resolution ├── otel.ts — OTel SDK setup and instruments ├── probe.ts — OTLP endpoint TCP probe -├── util.ts — errorSummary, setBoundedMap +├── headers.ts — Dynamic OTLP headers / refreshing exporters +├── trace-context.ts — W3C trace-context inject/extract +├── util.ts — errorSummary, setBoundedMap, context resolution, attrs └── handlers/ - ├── session.ts — Session lifecycle events - ├── message.ts — LLM message and tool part events - ├── permission.ts — Tool permission events - └── activity.ts — File diffs and git commits + ├── session.ts — session.created, session.execution.*, session.status/idle, usage + ├── step.ts — session.step.* (LLM spans + token/cost/cache metrics) + ├── tool.ts — session.tool.* (tool spans, duration, commit detection) + ├── permission.ts — permission.asked/replied + └── chat-headers.ts — session.hook("model.request") trace propagation ``` ## Key conventions @@ -40,14 +51,16 @@ src/ - **Bun over Node** — use `bun`, `bun test`, `bun run`. Never use `node`, `npx`, `jest`, or `vitest`. - **No comments** unless explicitly requested. - **No `sdk-node`** — the OTel Node SDK meta-package is intentionally excluded; use individual packages. -- **`HandlerContext`** — all event handlers receive a `HandlerContext` (defined in `src/types.ts`). Do not import `client` or OTel globals directly inside handlers; thread them through the context. -- **`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. +- **`@opencode/plugin` is type-only** — import types from it (`import type { Plugin } from "@opencode/plugin"`); the plugin never imports it at runtime, so it stays a dev/optional peer dependency. +- **`HandlerContext`** — all event handlers receive a `HandlerContext` (defined in `src/types.ts`). Do not import OTel globals directly inside handlers; thread them through the context. +- **`setBoundedMap` / `markSeen`** — always use these for correlation maps (`toolMeta`, `stepMeta`, `pendingPrompts`, `pendingPermissions`, `seenEvents`) to prevent unbounded growth. +- **Single source of truth for tokens/cost** — token and cost counters are incremented once per `session.step.ended`/`failed`; session totals come from `session.usage.updated` (cumulative) with a per-step fallback. +- **Event de-duplication** — V2 may deliver the same event to multiple plugin instances; dedupe by `event.id` via `markSeen`. +- **Shutdown** — providers are flushed (never shut down) on plugin cleanup and once per process on `beforeExit`. Shutting down the global OTel providers poisons them for the rest of the process. - **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_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. -- **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var. The plugin always loads regardless; only telemetry is disabled when unset. +- **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var (or the `enabled` plugin option). The plugin always loads regardless. - **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility. -- **Plugin options** — `loadConfig` also accepts an `OtelPluginOptions` object passed via opencode's plugin tuple form (`["opencode-plugin-otel", { ... }]`, threaded through `OtelPlugin`'s second argument). Precedence is option → `OPENCODE_*` env → default. Keep option keys 1:1 with `PluginConfig` field names. +- **Plugin options** — read from `ctx.options` during `setup`. Precedence is option → `OPENCODE_*` env → default. Keep option keys 1:1 with `PluginConfig` field names. ## Commit message format @@ -59,12 +72,5 @@ All commits must follow [Conventional Commits](https://www.conventionalcommits.o Common types: `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `ci`, `chore`, `build`. -Use `!` or a `BREAKING CHANGE:` footer for breaking changes. - -Examples: - -```text -feat(handlers): add support for file.edited event -fix(probe): handle malformed endpoint URL without throwing -chore(deps): bump @opentelemetry/api to 1.10.0 -``` +Use `!` or a `BREAKING CHANGE:` footer for breaking changes (this repo uses `release-please`, so a +breaking commit bumps the major version automatically). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8322058..037e289 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,12 +20,15 @@ Point your local opencode config at the repo so changes are picked up immediatel ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["/path/to/opencode-plugin-otel/src/index.ts"] + "plugins": [{ "package": "/path/to/opencode-plugin-otel/src/index.ts" }] } ``` opencode loads TypeScript natively via Bun, so there is no build step required during development. +> **Branching:** `main` targets OpenCode V2. The OpenCode V1 plugin is maintained on the `v1` branch +> (branched from the last `1.x` tag) — open V1 bug/security fixes against `v1`, not `main`. + ## Commands | Command | Description | @@ -39,17 +42,20 @@ opencode loads TypeScript natively via Bun, so there is no build step required d ```text src/ -├── index.ts — Plugin entrypoint, wires everything together +├── index.ts — Plugin entrypoint (V2 default export) +├── plugin.ts — setup(): config, hooks, event subscription +├── state.ts — shared OTel SDK + tracing state ├── types.ts — Shared types (Level, HandlerContext, Instruments, etc.) ├── config.ts — Environment config loading and log level resolution ├── otel.ts — OTel SDK setup, resource construction, instrument creation ├── probe.ts — TCP connectivity probe for the OTLP endpoint ├── util.ts — Utility functions (errorSummary, setBoundedMap) └── handlers/ - ├── session.ts — session.created / session.idle / session.error - ├── message.ts — message.updated / message.part.updated - ├── permission.ts — permission.updated / permission.replied - └── activity.ts — session.diff / command.executed + ├── session.ts — session.created / session.execution.* / session.status + ├── step.ts — session.step.* (LLM spans + token/cost metrics) + ├── tool.ts — session.tool.* (tool spans, duration, commits) + ├── permission.ts — permission.asked / permission.replied + └── chat-headers.ts — model.request trace propagation ``` ## Testing locally with a collector diff --git a/README.md b/README.md index 36ae0a7..cd3e657 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemetry (OTLP over gRPC or HTTP/protobuf), mirroring the same signals as [Claude Code's monitoring](https://code.claude.com/docs/en/monitoring-usage). +> **OpenCode V2 only.** Version `2.x` of this plugin targets **OpenCode `>=2`** and exports the V2 +> `Plugin.define`-style default export (`id: "devtheops.otel"`). The OpenCode V1 plugin is maintained +> on the [`v1` branch](https://github.com/DEVtheOPS/opencode-plugin-otel/tree/v1) and the `1.x` release +> line. See [OpenCode version support](#opencode-version-support). + +- [OpenCode version support](#opencode-version-support) - [What it instruments](#what-it-instruments) - [Metrics](#metrics) - [Log events](#log-events) @@ -29,6 +35,24 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [Local development](#local-development) - [GitHub Discord notifications](#github-discord-notifications) +## OpenCode version support + +| Plugin line | OpenCode | Config key | Entrypoint | +|-------------|----------|------------|------------| +| `2.x` (`main`) | V2 (`>=2`) | `plugins` | default export `{ id, setup }` | +| `1.x` (`v1` branch) | V1 | `plugin` | named `OtelPlugin` export | + +OpenCode V2 replaced the coarse V1 event stream (`message.updated`, `message.part.updated`, +`permission.*`, `command.executed`, `session.diff`) with a granular taxonomy +(`session.execution.*`, `session.step.*`, `session.tool.*`, `session.usage.updated`, +`session.retry.scheduled`). This plugin consumes the V2 stream directly, which produces more +accurate LLM and tool span timings than the V1 implementation. + +**Signals not available in V2:** the `lines_of_code.count` / `lines_of_code.total` metrics relied on the +V1 `session.diff` event and are not emitted (the V2 plugin context does not expose a session diff). +`command.executed`-based instrumentation is also gone; git commit detection is preserved by inspecting +shell tool input. Message/part spans are replaced by richer per-step LLM spans. + ## What it instruments ### Metrics @@ -36,19 +60,18 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet | Metric | Type | Description | |--------|------|-------------| | `opencode.session.count` | Counter | Incremented on each `session.created` event | -| `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` | -| `opencode.cost.usage` | Counter | USD cost per completed assistant message | -| `opencode.lines_of_code.count` | Counter | **Gross positive churn, not a net total.** Emits the positive delta of `additions`/`deletions` since the previous `session.diff` for the same session; negative deltas (when opencode's cumulative `additions` or `deletions` shrinks vs. the last event) are dropped. Summing the counter therefore reports gross lines added/removed across forward transitions — it does *not* reconcile back to the session's current state after any revert (full or partial). Intra-message rewrites that opencode collapses in its per-message cumulative are not visible here at all. Use `opencode.lines_of_code.total` for the authoritative live cumulative. | -| `opencode.lines_of_code.total` | Gauge | **Authoritative live cumulative lines added/removed for the session.** Refreshed on every `session.diff` with opencode's current cumulative value. Drops back to `0` if opencode reports a revert to baseline, and tracks partial reverts faithfully. Query this (not the counter) to answer "what does this session currently amount to". | -| `opencode.commit.count` | Counter | Git commits detected via bash tool | +| `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` (per `session.step.ended`) | +| `opencode.cost.usage` | Counter | USD cost per completed LLM step | +| `opencode.commit.count` | Counter | Git commits detected via shell tool input | | `opencode.tool.duration` | Histogram | Tool execution time in milliseconds | -| `opencode.cache.count` | Counter | Cache activity per message: `type=cacheRead` or `type=cacheCreation` | +| `opencode.cache.count` | Counter | Cache activity per step: `type=cacheRead` or `type=cacheCreation` | | `opencode.session.duration` | Histogram | Session duration from created to idle in milliseconds | | `opencode.message.count` | Counter | Completed assistant messages per session | -| `opencode.session.token.total` | Histogram | Total tokens consumed per session, recorded on idle | -| `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded on idle | +| `opencode.session.token.total` | Histogram | Total tokens consumed per session, recorded when an execution ends | +| `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded when an execution ends | | `opencode.model.usage` | Counter | Messages per model and provider | -| `opencode.retry.count` | Counter | API retries observed via `session.status` events | +| `opencode.retry.count` | Counter | API retries observed via `session.retry.scheduled` / `session.status` | +| `opencode.subtask.count` | Counter | Sub-agent sessions observed via `session.created` with a `parentID` | ### Log events @@ -56,10 +79,10 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet |-------|-------------| | `session.created` | Session started | | `session.idle` | Session went idle (includes total tokens, cost, messages) | -| `session.error` | Session error | -| `user_prompt` | User sent a message (includes `prompt_length`, `model`, `agent`; also `prompt` when `OPENCODE_CAPTURE_PROMPT_IN_LOGS` is set) | -| `api_request` | Completed assistant message (tokens, cost, duration) | -| `api_error` | Failed assistant message (error summary, duration) | +| `session.error` | Execution failed | +| `user_prompt` | User sent a message (includes `prompt_length`, `delivery`; also `prompt` when `OPENCODE_CAPTURE_PROMPT_IN_LOGS` is set) | +| `api_request` | Completed LLM step (tokens, cost) | +| `api_error` | Failed LLM step (error summary) | | `tool_result` | Tool completed or errored (duration, success, output size) | | `tool_decision` | Permission prompt answered (accept/reject) | | `commit` | Git commit detected | @@ -71,7 +94,7 @@ Add the plugin to your opencode config at `~/.config/opencode/opencode.json`: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["@devtheops/opencode-plugin-otel"] + "plugins": ["@devtheops/opencode-plugin-otel"] } ``` @@ -80,7 +103,7 @@ Or point directly at a local checkout for development: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["/path/to/opencode-plugin-otel/src/index.ts"] + "plugins": [{ "package": "/path/to/opencode-plugin-otel/src/index.ts" }] } ``` @@ -115,20 +138,23 @@ Prompt logging remains disabled by default. Enable it only when the configured t ### Plugin options (opencode.json) -Every setting can also be passed inline through opencode's plugin **tuple form**, so nothing has to be exported in a shell. Options take precedence over the matching `OPENCODE_*` environment variable, which in turn wins over the built-in default. +Every setting can also be passed inline through opencode's plugin **object form**, so nothing has to be exported in a shell. Options take precedence over the matching `OPENCODE_*` environment variable, which in turn wins over the built-in default. ```json { "$schema": "https://opencode.ai/config.json", - "plugin": [ - ["@devtheops/opencode-plugin-otel", { - "enabled": true, - "endpoint": "http://localhost:4317", - "protocol": "grpc", - "metricPrefix": "claude_code.", - "resourceAttributes": "service.version=1.2.3,deployment.environment=production", - "disabledTraces": ["tool"] - }] + "plugins": [ + { + "package": "@devtheops/opencode-plugin-otel", + "options": { + "enabled": true, + "endpoint": "http://localhost:4317", + "protocol": "grpc", + "metricPrefix": "claude_code.", + "resourceAttributes": "service.version=1.2.3,deployment.environment=production", + "disabledTraces": ["tool"] + } + } ] } ``` @@ -140,6 +166,7 @@ Option keys mirror the resolved config and map to the environment variables: | `enabled` | `OPENCODE_ENABLE_TELEMETRY` | | `logsEnabled` | `OPENCODE_DISABLE_LOGS` (inverted) | | `capturePromptInLogs` | `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | +| `logLevel` | *(none — option only)*: `debug`, `info`, `warn`, `error` | | `endpoint` | `OPENCODE_OTLP_ENDPOINT` | | `protocol` | `OPENCODE_OTLP_PROTOCOL` | | `metricsInterval` | `OPENCODE_OTLP_METRICS_INTERVAL` | @@ -240,9 +267,6 @@ export OPENCODE_DISABLE_METRICS="retry.count" # Disable multiple metrics export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count" - -# Disable the new per-session cumulative gauge while keeping the delta counter -export OPENCODE_DISABLE_METRICS="lines_of_code.total" ``` #### opencode-only metrics diff --git a/bun.lock b/bun.lock index 79b6ec1..8afb800 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,6 @@ "name": "opencode-plugin-otel", "dependencies": { "@arizeai/openinference-semantic-conventions": "^2.2.0", - "@opencode-ai/plugin": "^1.14.20", - "@opencode-ai/sdk": "^1.14.20", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0", @@ -26,17 +24,80 @@ "@opentelemetry/semantic-conventions": "^1.40.0", }, "devDependencies": { + "@opencode/plugin": "^2.0.18", "@types/bun": "latest", "@typescript-eslint/parser": "^8.62.0", "eslint": "^9.24.0", "eslint-plugin-jsdoc": "^50.6.11", "typescript": "^5.9.3", }, + "peerDependencies": { + "@opencode/plugin": ">=2.0.0", + }, + "optionalPeers": [ + "@opencode/plugin", + ], }, }, "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@arizeai/openinference-semantic-conventions": ["@arizeai/openinference-semantic-conventions@2.2.0", "", {}, "sha512-jyVeggDSuVFyOXWXqShKsRxHc5RrE7jRN2D0LGMGYDbo4oHeCJCgUezHQITT7t7FPNQxj5O+NcqERhznnDx6UQ=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.978.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.0", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.71", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rsxsi8tF3vMGdWdaHQNXceHBdb49442yR2Pe08K+Phoc/yICbptdsHwL25Wf0z5+hjhSS5a+hDJHP7Xul4zvHQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.17", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-login": "^3.972.79", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.79", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.84", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-ini": "^3.973.17", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/token-providers": "3.1138.0", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.46", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/signature-v4-multi-region": "^3.996.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.47", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1138.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], + "@es-joy/jsdoccomment": ["@es-joy/jsdoccomment@0.50.2", "", { "dependencies": { "@types/estree": "^1.0.6", "@typescript-eslint/types": "^8.11.0", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~4.1.0" } }, "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -57,6 +118,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], @@ -69,28 +132,74 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.20", "", { "dependencies": { "@opencode-ai/sdk": "1.14.20", "effect": "4.0.0-beta.48", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.99", "@opentui/solid": ">=0.1.99" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-txRfm6Y2DBqFxJtgo63vj5//d7TJS7/angWELUEQcsSK0llfvmVp60gox4e8zIwpGjDcDP9A4qO6oNe++pockg=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.20", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-kPZP1An1ZdWOfLfDYhNjh665HX4RcI8au6Lzjn0FktoQ3RpWHq1WXRLHrJO8rJqwWvQDOzS48cXt9jbr+uwQiA=="], + "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="], + + "@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="], + + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="], + + "@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + + "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], + + "@opencode/ai": ["@opencode/ai@2.0.18", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.18", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-zzd01WCh4rdvA29DwUVhG3n8rnn4y8jAobBE34ot5ueRmljO8AZ61/fq0+89tcm2OtEkGoMiAoBALJqd2LaWtQ=="], + + "@opencode/client": ["@opencode/client@2.0.18", "", { "dependencies": { "@opencode/protocol": "2.0.18", "@opencode/schema": "2.0.18" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-N74FNdXkTTC1Re5T+PNxH6fsVf1tZiV3kESu3UWL/545oAkBRXoiikMmDszuuHZ4AxGwtr9NiQjKrHiMdnLVYQ=="], + + "@opencode/plugin": ["@opencode/plugin@2.0.18", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.18", "@opencode/client": "2.0.18", "@opencode/protocol": "2.0.18", "@opencode/schema": "2.0.18", "@opencode/util": "2.0.18", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.18", "@opentui/core": ">=0.5.12", "@opentui/solid": ">=0.5.12", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-jokYYdAGd/L+WY0GbEE1Bh1/F8tG98UaTlvvyBriOuVDyeadHFdTL9QoXx+u7pAWwJRTEMOESxxifS/blGPjyg=="], + + "@opencode/protocol": ["@opencode/protocol@2.0.18", "", { "dependencies": { "@opencode/schema": "2.0.18", "effect": "4.0.0-rc.112" } }, "sha512-IhzzlCz/hAlFvBLWQ+kDceX0/CkYOrjVyTPwYJYvWwKze5AHzVxxDYxkhCqLJj0hn/wtdNL10nhjVothuHcaBQ=="], + + "@opencode/schema": ["@opencode/schema@2.0.18", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-f+lp6f+kxhygdeSxjqkiu6vk9t0onH/6OVBghSNyo9ZUUFP9BU/2JKthMgy9kHGyFu2vp0lg01H9umXn76u52A=="], + + "@opencode/util": ["@opencode/util@2.0.18", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "open": "11.0.4", "pacote": "21.5.1" } }, "sha512-PmrOwB3k9nlubvM9ZIj08uann/SpPAfBt6fPI4OiRCnNKBNkEbU4d3CZIFL3QtxTCn1OPzF/AfRDQUuE4Tf+Mg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.8.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw=="], + "@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.213.0", "", { "dependencies": { "@grpc/grpc-js": "^1.14.3", "@opentelemetry/core": "2.6.0", "@opentelemetry/otlp-exporter-base": "0.213.0", "@opentelemetry/otlp-grpc-exporter-base": "0.213.0", "@opentelemetry/otlp-transformer": "0.213.0", "@opentelemetry/sdk-logs": "0.213.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg=="], @@ -125,8 +234,12 @@ "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ=="], + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.8.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -147,8 +260,56 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="], + + "@redis/client": ["@redis/client@6.2.1", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q=="], + + "@redis/json": ["@redis/json@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ=="], + + "@redis/search": ["@redis/search@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ=="], + + "@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="], + + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + + "@smithy/core": ["@smithy/core@3.35.0", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], + + "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "tslib": "^2.6.2" } }, "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -157,6 +318,8 @@ "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA=="], "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.0", "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ=="], @@ -171,10 +334,14 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -185,38 +352,82 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="], + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], @@ -239,7 +450,11 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-check": ["fast-check@4.10.2", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -249,9 +464,9 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -259,43 +474,103 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ip-address": ["ip-address@10.7.2", "", {}, "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsdoc-type-pratt-parser": ["jsdoc-type-pratt-parser@4.1.0", "", {}, "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -307,28 +582,84 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], - "msgpackr": ["msgpackr@1.11.10", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA=="], + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], - "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], + + "npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="], + + "npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + "parse-imports-exports": ["parse-imports-exports@0.2.4", "", { "dependencies": { "parse-statements": "1.0.11" } }, "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ=="], "parse-statements": ["parse-statements@1.0.11", "", {}, "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA=="], @@ -337,55 +668,111 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], + + "redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -393,9 +780,17 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -405,10 +800,40 @@ "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@es-joy/jsdoccomment/@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@npmcli/map-workspaces/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@opencode/util/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], + + "@opencode/util/@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-transformer": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw=="], + + "@opencode/util/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA=="], + + "@opencode/util/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], + + "@opencode/util/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + + "@opencode/util/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], @@ -473,10 +898,58 @@ "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + "@opentelemetry/sdk-trace-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + + "@tufjs/models/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/undici": ["undici@6.29.0", "", {}, "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@npmcli/arborist/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@npmcli/map-workspaces/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@opencode/util/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.219.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/otlp-transformer": "0.219.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA=="], + + "@opencode/util/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], + + "@opencode/util/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg=="], "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], @@ -485,8 +958,40 @@ "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.8.0", "", { "dependencies": { "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ=="], + "@tufjs/models/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "ignore-walk/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@npmcli/arborist/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@npmcli/map-workspaces/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@opencode/util/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@tufjs/models/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "ignore-walk/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], } } diff --git a/package.json b/package.json index d144bed..14fc6e2 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,6 @@ }, "dependencies": { "@arizeai/openinference-semantic-conventions": "^2.2.0", - "@opencode-ai/plugin": "^1.14.20", - "@opencode-ai/sdk": "^1.14.20", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0", @@ -26,6 +24,7 @@ }, "description": "OpenTelemetry telemetry plugin for opencode CLI", "devDependencies": { + "@opencode/plugin": "^2.0.18", "@types/bun": "latest", "@typescript-eslint/parser": "^8.62.0", "eslint": "^9.24.0", @@ -61,6 +60,14 @@ "oc-plugin": [ "server" ], + "peerDependencies": { + "@opencode/plugin": ">=2.0.0" + }, + "peerDependenciesMeta": { + "@opencode/plugin": { + "optional": true + } + }, "publishConfig": { "access": "public" }, diff --git a/src/config.ts b/src/config.ts index 6c235d5..7dfd2a0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ export type PluginConfig = { enabled: boolean logsEnabled: boolean capturePromptInLogs: boolean + logLevel: string | undefined endpoint: string protocol: "grpc" | "http/protobuf" | "http/json" metricsInterval: number @@ -58,6 +59,7 @@ export type OtelPluginOptions = { enabled?: boolean logsEnabled?: boolean capturePromptInLogs?: boolean + logLevel?: string endpoint?: string protocol?: "grpc" | "http/protobuf" | "http/json" metricsInterval?: number @@ -197,6 +199,7 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"), logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"), capturePromptInLogs: pickBoolean(resolvedOptions.capturePromptInLogs) ?? hasNonEmptyEnv("OPENCODE_CAPTURE_PROMPT_IN_LOGS"), + logLevel: pickString(resolvedOptions.logLevel), endpoint: pickString(resolvedOptions.endpoint) ?? process.env["OPENCODE_OTLP_ENDPOINT"] ?? "http://localhost:4317", protocol, metricsInterval: pickPositiveInt(resolvedOptions.metricsInterval) ?? parseEnvInt("OPENCODE_OTLP_METRICS_INTERVAL", 60000), diff --git a/src/handlers/activity.ts b/src/handlers/activity.ts deleted file mode 100644 index 7ef47df..0000000 --- a/src/handlers/activity.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { SeverityNumber } from "@opentelemetry/api-logs" -import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" -import { agentAttrs, getSessionAgentMeta, isMetricEnabled, setBoundedMap } from "../util.ts" -import type { HandlerContext } from "../types.ts" - -/** - * Records lines-added/removed for a `session.diff` event. opencode publishes each event - * with the cumulative session diff (first snapshot → latest), so we emit two instruments: - * `opencode.lines_of_code.count` (Counter) receives only the *positive* per-event delta - * for each dimension (additions, deletions). Negative deltas — opencode reporting a smaller - * cumulative for a dimension than the previous event — are dropped, so the counter reports - * gross positive churn and does not reconcile to net after any revert (full or partial). - * `opencode.lines_of_code.total` (Gauge) mirrors opencode's current cumulative value on - * every event and is the authoritative live view. - */ -export function handleSessionDiff(e: EventSessionDiff, ctx: HandlerContext) { - const sessionID = e.properties.sessionID - const linesEnabled = isMetricEnabled("lines_of_code.count", ctx) - const totalEnabled = isMetricEnabled("lines_of_code.total", ctx) - let totalAdded = 0 - let totalRemoved = 0 - for (const fileDiff of e.properties.diff) { - totalAdded += fileDiff.additions - totalRemoved += fileDiff.deletions - } - - const prev = ctx.sessionDiffTotals.get(sessionID) ?? { additions: 0, deletions: 0 } - const deltaAdded = totalAdded - prev.additions - const deltaRemoved = totalRemoved - prev.deletions - const nextTotals = { additions: totalAdded, deletions: totalRemoved } - setBoundedMap(ctx.sessionDiffTotals, sessionID, nextTotals) - - const baseAttrs = { ...ctx.commonAttrs, "session.id": sessionID } - - if (linesEnabled) { - if (deltaAdded > 0) { - ctx.instruments.linesCounter.add(deltaAdded, { ...baseAttrs, type: "added" }) - } - if (deltaRemoved > 0) { - ctx.instruments.linesCounter.add(deltaRemoved, { ...baseAttrs, type: "removed" }) - } - } - if (totalEnabled) { - ctx.instruments.linesTotalGauge.record(totalAdded, { ...baseAttrs, type: "added" }) - ctx.instruments.linesTotalGauge.record(totalRemoved, { ...baseAttrs, type: "removed" }) - } - - ctx.log("debug", "otel: lines_of_code metrics updated", { - sessionID, - files: e.properties.diff.length, - deltaAdded, - deltaRemoved, - totalAdded, - totalRemoved, - }) -} - -const GIT_COMMIT_RE = /\bgit\s+commit(?![-\w])/ - -/** Detects `git commit` invocations in bash tool calls and increments the commit counter and emits a `commit` log event. */ -export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerContext) { - if (e.properties.name !== "bash") return - ctx.log("debug", "otel: command.executed (bash)", { sessionID: e.properties.sessionID, argumentsLength: e.properties.arguments.length }) - if (!GIT_COMMIT_RE.test(e.properties.arguments)) return - const { agentName, agentType } = getSessionAgentMeta(e.properties.sessionID, ctx) - - if (isMetricEnabled("commit.count", ctx)) { - ctx.instruments.commitCounter.add(1, { - ...ctx.commonAttrs, - "session.id": e.properties.sessionID, - }) - ctx.log("debug", "otel: commit counter incremented", { sessionID: e.properties.sessionID }) - } - ctx.emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: Date.now(), - observedTimestamp: Date.now(), - body: "commit", - attributes: { - "event.name": "commit", - "session.id": e.properties.sessionID, - ...agentAttrs(agentName, agentType), - ...ctx.commonAttrs, - }, - }) -} diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index 626c7e5..0f0bb65 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -1,23 +1,20 @@ -import type { ProviderContext } from "@opencode-ai/plugin" -import type { Model, UserMessage } from "@opencode-ai/sdk" import type { HandlerContext } from "../types.ts" import { injectTraceContext } from "../trace-context.ts" -/** Injects the matching LLM span context for explicitly enabled providers. */ -export function handleChatHeaders( - input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, - output: { headers: Record }, - ctx: HandlerContext, -): void { - const providerID = input.model.providerID - if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return - - const request = ctx.llmRequestContexts.get(`${input.sessionID}:${input.message.id}`)?.findLast(candidate => - candidate.agent === input.agent - && candidate.modelID === input.model.id - && candidate.providerID === providerID - ) - if (!request) return +/** The `session.hook("model.request", ...)` event shape consumed by this handler. */ +export type ModelRequestEvent = { + sessionID: string + agent: string + model: { providerID: string; id: string } + kind: string + headers: Record +} - injectTraceContext(request.spanContext, output.headers) +/** Injects the active LLM span's W3C trace context into outbound model requests. */ +export function handleModelRequest(event: ModelRequestEvent, ctx: HandlerContext): void { + const providerID = event.model.providerID + if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return + const active = ctx.tracing.activeLlm.get(event.sessionID) + if (!active || active.providerID !== providerID) return + injectTraceContext(active.spanContext, event.headers) } diff --git a/src/handlers/message.ts b/src/handlers/message.ts deleted file mode 100644 index 248228a..0000000 --- a/src/handlers/message.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { SeverityNumber } from "@opentelemetry/api-logs" -import { SpanStatusCode, SpanKind } from "@opentelemetry/api" -import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, ToolPart } from "@opencode-ai/sdk" -import { - AGENT_NAME, - INPUT_MIME_TYPE, - INPUT_VALUE, - LLM_COST_TOTAL, - LLM_INPUT_MESSAGES, - LLM_MODEL_NAME, - LLM_OUTPUT_MESSAGES, - LLM_PROVIDER, - LLM_SYSTEM, - LLM_TOKEN_COUNT_COMPLETION, - LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, - LLM_TOKEN_COUNT_PROMPT, - LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ, - LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE, - LLM_TOKEN_COUNT_TOTAL, - MimeType, - OpenInferenceSpanKind, - OUTPUT_MIME_TYPE, - OUTPUT_VALUE, - SemanticConventions, - SESSION_ID, - TOOL_ID, - TOOL_NAME, - TOOL_PARAMETERS, -} from "@arizeai/openinference-semantic-conventions" -import { - agentAttrs, - errorSummary, - genAiProviderName, - setBoundedMap, - accumulateSessionTotals, - getSessionAgentMeta, - isMetricEnabled, - isTraceEnabled, - resolveSessionTraceContext, -} from "../util.ts" -import type { HandlerContext } from "../types.ts" - -const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND -const LLM_FINISH_REASON = "llm.finish_reason" - -type SubtaskPart = { - type: "subtask" - sessionID: string - messageID: string - prompt: string - description: string - agent: string -} - -/** - * Handles a completed assistant message: increments token and cost counters, emits - * either an `api_request` or `api_error` log event, and ends the LLM span for this message. - * The `agent` attribute is sourced from the session totals, which are populated by the - * `chat.message` hook when the user prompt is received. - */ -export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext) { - const msg = e.properties.info - if (msg.role !== "assistant") return - const assistant = msg as AssistantMessage - setBoundedMap(ctx.assistantRuns, assistant.id, assistant.parentID) - if (!assistant.time.completed) return - - const { sessionID, modelID, providerID } = assistant - const duration = assistant.time.completed - assistant.time.created - const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) - const agent = agentName - - const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning - + assistant.tokens.cache.read + assistant.tokens.cache.write - - if (isMetricEnabled("token.usage", ctx)) { - const { tokenCounter } = ctx.instruments - tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "input" }) - tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "output" }) - tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "reasoning" }) - tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" }) - tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" }) - } - - if (isMetricEnabled("cost.usage", ctx)) { - ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) - } - - if (isMetricEnabled("cache.count", ctx)) { - if (assistant.tokens.cache.read > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" }) - } - if (assistant.tokens.cache.write > 0) { - ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" }) - } - } - - if (isMetricEnabled("message.count", ctx)) { - ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) - } - - if (isMetricEnabled("model.usage", ctx)) { - ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID, agent }) - } - - accumulateSessionTotals(sessionID, totalTokens, assistant.cost, ctx) - - ctx.log("debug", "otel: token+cost counters incremented", { - sessionID, - model: modelID, - agent, - input: assistant.tokens.input, - output: assistant.tokens.output, - reasoning: assistant.tokens.reasoning, - cacheRead: assistant.tokens.cache.read, - cacheWrite: assistant.tokens.cache.write, - cost_usd: assistant.cost, - }) - - const msgKey = `${sessionID}:${assistant.id}` - const msgSpan = ctx.messageSpans.get(msgKey) - if (msgSpan) { - const outputText = ctx.messageOutputs.get(msgKey) - msgSpan.setAttributes({ - [AGENT_NAME]: agentName, - "agent.type": agentType, - [LLM_TOKEN_COUNT_PROMPT]: assistant.tokens.input, - [LLM_TOKEN_COUNT_COMPLETION]: assistant.tokens.output, - [LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: assistant.tokens.reasoning, - [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: assistant.tokens.cache.read, - [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: assistant.tokens.cache.write, - [LLM_TOKEN_COUNT_TOTAL]: totalTokens, - [LLM_FINISH_REASON]: assistant.error ? "error" : (assistant.finish ?? "stop"), - [LLM_COST_TOTAL]: assistant.cost, - ...(outputText - ? { - [OUTPUT_VALUE]: outputText, - [OUTPUT_MIME_TYPE]: MimeType.TEXT, - [LLM_OUTPUT_MESSAGES]: JSON.stringify([{ role: "assistant", content: outputText }]), - } - : {}), - cost_usd: assistant.cost, - duration_ms: duration, - }) - if (assistant.error) { - msgSpan.setStatus({ code: SpanStatusCode.ERROR, message: errorSummary(assistant.error) }) - } else { - msgSpan.setStatus({ code: SpanStatusCode.OK }) - } - msgSpan.end(assistant.time.completed) - ctx.messageSpans.delete(msgKey) - ctx.messageOutputs.delete(msgKey) - } - const requestKey = `${sessionID}:${assistant.parentID}` - const remainingRequests = ctx.llmRequestContexts.get(requestKey)?.filter(request => request.messageID !== assistant.id) - if (remainingRequests?.length) { - setBoundedMap(ctx.llmRequestContexts, requestKey, remainingRequests) - } else { - ctx.llmRequestContexts.delete(requestKey) - } - - if (assistant.error) { - ctx.emitLog({ - severityNumber: SeverityNumber.ERROR, - severityText: "ERROR", - timestamp: assistant.time.created, - observedTimestamp: Date.now(), - body: "api_error", - attributes: { - "event.name": "api_error", - "session.id": sessionID, - model: modelID, - provider: providerID, - "gen_ai.provider.name": genAiProviderName(providerID), - ...agentAttrs(agentName, agentType), - error: errorSummary(assistant.error), - duration_ms: duration, - ...ctx.commonAttrs, - }, - }) - return ctx.log("error", "otel: api_error", { - sessionID, - model: modelID, - agent, - error: errorSummary(assistant.error), - duration_ms: duration, - }) - } - - ctx.emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: assistant.time.created, - observedTimestamp: Date.now(), - body: "api_request", - attributes: { - "event.name": "api_request", - "session.id": sessionID, - model: modelID, - provider: providerID, - "gen_ai.provider.name": genAiProviderName(providerID), - ...agentAttrs(agentName, agentType), - cost_usd: assistant.cost, - duration_ms: duration, - input_tokens: assistant.tokens.input, - output_tokens: assistant.tokens.output, - reasoning_tokens: assistant.tokens.reasoning, - cache_read_tokens: assistant.tokens.cache.read, - cache_creation_tokens: assistant.tokens.cache.write, - ...ctx.commonAttrs, - }, - }) - return ctx.log("info", "otel: api_request", { - sessionID, - model: modelID, - agent, - cost_usd: assistant.cost, - duration_ms: duration, - input_tokens: assistant.tokens.input, - output_tokens: assistant.tokens.output, - }) -} - -/** - * Tracks tool execution time between `running` and `completed`/`error` part updates, - * records a `tool.duration` histogram measurement, manages the tool child span, and emits - * a `tool_result` log event. Also handles `subtask` parts, incrementing the sub-agent - * invocation counter and emitting a `subtask_invoked` log event. - * - * For tool spans: on `running` a child span of the current session span is started and stored - * in `pendingToolSpans`. On `completed`/`error` the span is ended with appropriate status. - * If no `running` event was seen (out-of-order), a best-effort span is started and immediately ended. - */ -export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: HandlerContext) { - const part = e.properties.part - - if (part.type === "text") { - const key = `${part.sessionID}:${part.messageID}` - ctx.messageOutputs.set(key, `${ctx.messageOutputs.get(key) ?? ""}${part.text}`) - return - } - - if (part.type === "subtask") { - const subtask = part as unknown as SubtaskPart - if (isMetricEnabled("subtask.count", ctx)) { - ctx.instruments.subtaskCounter.add(1, { - ...ctx.commonAttrs, - "session.id": subtask.sessionID, - agent: subtask.agent, - "agent.type": "subagent", - }) - } - ctx.emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: Date.now(), - observedTimestamp: Date.now(), - body: "subtask_invoked", - attributes: { - "event.name": "subtask_invoked", - "session.id": subtask.sessionID, - ...agentAttrs(subtask.agent, "subagent"), - description: subtask.description, - prompt_length: subtask.prompt.length, - ...ctx.commonAttrs, - }, - }) - return ctx.log("info", "otel: subtask_invoked", { - sessionID: subtask.sessionID, - agent: subtask.agent, - description: subtask.description, - }) - } - - if (part.type === "tool") { - const toolPart = part as ToolPart - const key = `${toolPart.sessionID}:${toolPart.callID}` - - if (toolPart.state.status === "running") { - const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx) - const toolSpan = isTraceEnabled("tool", ctx) - ? (() => { - return ctx.tracer.startSpan( - `${ctx.tracePrefix}tool.${toolPart.tool}`, - { - startTime: toolPart.state.time.start, - kind: SpanKind.INTERNAL, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, - [SESSION_ID]: toolPart.sessionID, - [TOOL_ID]: toolPart.callID, - [TOOL_NAME]: toolPart.tool, - [TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input), - [INPUT_VALUE]: JSON.stringify(toolPart.state.input), - [INPUT_MIME_TYPE]: MimeType.JSON, - [AGENT_NAME]: agentName, - "agent.type": agentType, - ...ctx.commonAttrs, - }, - }, - resolveSessionTraceContext(toolPart.sessionID, ctx, { - assistantMessageID: toolPart.messageID, - }), - ) - })() - : undefined - setBoundedMap(ctx.pendingToolSpans, key, { - tool: toolPart.tool, - sessionID: toolPart.sessionID, - startMs: toolPart.state.time.start, - span: toolSpan, - }) - ctx.log("debug", "otel: tool span started", { sessionID: toolPart.sessionID, tool: toolPart.tool, key }) - return - } - - if (toolPart.state.status !== "completed" && toolPart.state.status !== "error") return - - const pending = ctx.pendingToolSpans.get(key) - ctx.pendingToolSpans.delete(key) - const start = pending?.startMs ?? toolPart.state.time.start - const end = toolPart.state.time.end - if (end === undefined) return - const duration_ms = end - start - const success = toolPart.state.status === "completed" - const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx) - - if (isMetricEnabled("tool.duration", ctx)) { - ctx.instruments.toolDurationHistogram.record(duration_ms, { - ...ctx.commonAttrs, - "session.id": toolPart.sessionID, - tool_name: toolPart.tool, - success, - }) - } - - if (isTraceEnabled("tool", ctx)) { - const toolSpan = pending?.span ?? (() => { - return ctx.tracer.startSpan( - `${ctx.tracePrefix}tool.${toolPart.tool}`, - { - startTime: start, - kind: SpanKind.INTERNAL, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, - [SESSION_ID]: toolPart.sessionID, - [TOOL_ID]: toolPart.callID, - [TOOL_NAME]: toolPart.tool, - [TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input), - [INPUT_VALUE]: JSON.stringify(toolPart.state.input), - [INPUT_MIME_TYPE]: MimeType.JSON, - ...ctx.commonAttrs, - }, - }, - resolveSessionTraceContext(toolPart.sessionID, ctx, { - assistantMessageID: toolPart.messageID, - }), - ) - })() - toolSpan.setAttributes({ [AGENT_NAME]: agentName, "agent.type": agentType }) - toolSpan.setAttribute("tool.success", success) - if (success) { - const output = (toolPart.state as { output: string }).output - toolSpan.setAttributes({ - [OUTPUT_VALUE]: output, - [OUTPUT_MIME_TYPE]: MimeType.TEXT, - }) - toolSpan.setAttribute("tool.result_size_bytes", Buffer.byteLength(output, "utf8")) - toolSpan.setStatus({ code: SpanStatusCode.OK }) - } else { - const err = (toolPart.state as { error: string }).error - toolSpan.setAttributes({ - [OUTPUT_VALUE]: err, - [OUTPUT_MIME_TYPE]: MimeType.TEXT, - }) - toolSpan.setAttribute("tool.error", err) - toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: err }) - } - toolSpan.end(end) - } - - const sizeAttr = success - ? { tool_result_size_bytes: Buffer.byteLength((toolPart.state as { output: string }).output, "utf8") } - : { error: (toolPart.state as { error: string }).error } - - ctx.emitLog({ - severityNumber: success ? SeverityNumber.INFO : SeverityNumber.ERROR, - severityText: success ? "INFO" : "ERROR", - timestamp: start, - observedTimestamp: Date.now(), - body: "tool_result", - attributes: { - "event.name": "tool_result", - "session.id": toolPart.sessionID, - tool_name: toolPart.tool, - ...agentAttrs(agentName, agentType), - success, - duration_ms, - ...sizeAttr, - ...ctx.commonAttrs, - }, - }) - ctx.log("debug", "otel: tool.duration histogram recorded", { - sessionID: toolPart.sessionID, - tool_name: toolPart.tool, - duration_ms, - success, - }) - return ctx.log(success ? "info" : "error", "otel: tool_result", { - sessionID: toolPart.sessionID, - tool_name: toolPart.tool, - success, - duration_ms, - }) - } -} - -/** - * Starts an LLM span for an assistant message when it first appears in `message.updated`. - * The span is parented to the active run or subagent span and carries `gen_ai.*` semantic - * attributes for the model and provider. It is ended in `handleMessageUpdated` once the - * message completes. - * - * Only called for assistant messages that have not yet completed (`time.completed` absent). - */ -export function startMessageSpan( - sessionID: string, - messageID: string, - parentID: string, - modelID: string, - providerID: string, - startTime: number, - ctx: HandlerContext, - agent?: string, -) { - if (!isTraceEnabled("llm", ctx)) return - const msgKey = `${sessionID}:${messageID}` - if (ctx.messageSpans.has(msgKey)) return - setBoundedMap(ctx.assistantRuns, messageID, parentID) - const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) - const inputText = ctx.runInputs.get(parentID) - - const msgSpan = ctx.tracer.startSpan( - `${ctx.tracePrefix}llm`, - { - startTime, - kind: SpanKind.CLIENT, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, - [SESSION_ID]: sessionID, - [AGENT_NAME]: agentName, - "agent.type": agentType, - [LLM_SYSTEM]: providerID, - [LLM_PROVIDER]: providerID, - "gen_ai.provider.name": genAiProviderName(providerID), - [LLM_MODEL_NAME]: modelID, - ...(inputText - ? { - [INPUT_VALUE]: inputText, - [INPUT_MIME_TYPE]: MimeType.TEXT, - [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: inputText }]), - } - : {}), - ...ctx.commonAttrs, - }, - }, - resolveSessionTraceContext(sessionID, ctx, { runID: parentID, assistantMessageID: messageID }), - ) - setBoundedMap(ctx.messageSpans, msgKey, msgSpan) - const requestKey = `${sessionID}:${parentID}` - setBoundedMap(ctx.llmRequestContexts, requestKey, [ - ...(ctx.llmRequestContexts.get(requestKey) ?? []), - { - messageID, - agent: agent ?? agentName, - modelID, - providerID, - spanContext: msgSpan.spanContext(), - }, - ]) -} diff --git a/src/handlers/permission.ts b/src/handlers/permission.ts index 74d9834..ba57fbe 100644 --- a/src/handlers/permission.ts +++ b/src/handlers/permission.ts @@ -1,40 +1,43 @@ import { SeverityNumber } from "@opentelemetry/api-logs" -import type { EventPermissionUpdated, EventPermissionReplied } from "@opencode-ai/sdk" +import type { EventOf, HandlerContext } from "../types.ts" import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "../util.ts" -import type { HandlerContext } from "../types.ts" -/** Stores a pending permission prompt in the context map for later correlation with its reply. */ -export function handlePermissionUpdated(e: EventPermissionUpdated, ctx: HandlerContext) { - const perm = e.properties - setBoundedMap(ctx.pendingPermissions, perm.id, { - type: perm.type, - title: perm.title, - sessionID: perm.sessionID, +/** Stores an answered permission prompt for correlation when the reply arrives. */ +export function handlePermissionAsked(e: EventOf<"permission.asked">, ctx: HandlerContext) { + const d = e.data + setBoundedMap(ctx.tracing.pendingPermissions, d.id, { + action: d.action, + resources: d.resources, + sessionID: d.sessionID, + }) + void ctx.log("debug", "otel: permission asked", { + requestID: d.id, + sessionID: d.sessionID, + action: d.action, }) - ctx.log("debug", "otel: permission stored", { permissionID: perm.id, sessionID: perm.sessionID, type: perm.type, title: perm.title }) } /** Emits a `tool_decision` log event recording whether the permission was accepted or rejected. */ -export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerContext) { - const { permissionID, sessionID, response } = e.properties - const pending = ctx.pendingPermissions.get(permissionID) - ctx.pendingPermissions.delete(permissionID) - const decision = response === "allow" || response === "allowAlways" ? "accept" : "reject" - const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) - ctx.log("debug", "otel: tool_decision emitted", { permissionID, sessionID, decision, source: response, tool_name: pending?.title ?? "unknown" }) +export function handlePermissionReplied(e: EventOf<"permission.replied">, ctx: HandlerContext) { + const d = e.data + const pending = ctx.tracing.pendingPermissions.get(d.requestID) + ctx.tracing.pendingPermissions.delete(d.requestID) + const decision = d.reply === "reject" ? "reject" : "accept" + const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) + ctx.emitLog({ severityNumber: SeverityNumber.INFO, severityText: "INFO", - timestamp: Date.now(), + timestamp: e.created, observedTimestamp: Date.now(), body: "tool_decision", attributes: { "event.name": "tool_decision", - "session.id": sessionID, - tool_name: pending?.title ?? "unknown", - tool_type: pending?.type ?? "unknown", + "session.id": d.sessionID, + tool_name: pending?.action ?? "unknown", + ...(pending ? { resources: pending.resources.join(",") } : {}), decision, - source: response, + source: d.reply, ...agentAttrs(agentName, agentType), ...ctx.commonAttrs, }, diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9a952dc..33dbc58 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -1,6 +1,5 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import { SpanStatusCode } from "@opentelemetry/api" -import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" import { AGENT_NAME, INPUT_MIME_TYPE, @@ -11,181 +10,162 @@ import { SemanticConventions, SESSION_ID, } from "@arizeai/openinference-semantic-conventions" +import type { EventOf, HandlerContext, SessionAgentType, SessionTotals } from "../types.ts" import { agentAttrs, errorSummary, - getSessionAgentMeta, - setBoundedMap, isMetricEnabled, isTraceEnabled, - resolveSessionTraceContext, + modelRef, + resolveRunContext, + setBoundedMap, + totalTokens, } from "../util.ts" -import type { HandlerContext, SessionAgentType } from "../types.ts" const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND -/** Starts or refreshes the root run span for a single user turn, keyed by the user message ID. */ -export function handleRunStarted( - runID: string, - sessionID: string, - agent: string, - promptText: string, - model: string, - startTime: number, - ctx: HandlerContext, -) { - ctx.activeRuns.set(sessionID, runID) - ctx.pendingRuns.delete(sessionID) - if (promptText) setBoundedMap(ctx.runInputs, runID, promptText) - if (!isTraceEnabled("session", ctx)) return - const existing = ctx.runSpans.get(runID) - if (existing) { - existing.setAttributes({ - [AGENT_NAME]: agent, - ...(promptText - ? { - [INPUT_VALUE]: promptText, - [INPUT_MIME_TYPE]: MimeType.TEXT, - [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), - } - : {}), - model, +function countSession(sessionID: string, isSubagent: boolean, ctx: HandlerContext) { + if (ctx.tracing.countedSessions.has(sessionID)) return + ctx.tracing.countedSessions.add(sessionID) + if (isMetricEnabled("session.count", ctx)) { + ctx.instruments.sessionCounter.add(1, { + ...ctx.commonAttrs, + "session.id": sessionID, + is_subagent: isSubagent, }) - return } +} - const runSpan = ctx.tracer.startSpan( - `${ctx.tracePrefix}session`, - { - startTime, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT, - [SESSION_ID]: sessionID, - [AGENT_NAME]: agent, - "agent.type": "primary", - "session.is_subagent": false, - ...(promptText - ? { - [INPUT_VALUE]: promptText, - [INPUT_MIME_TYPE]: MimeType.TEXT, - [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), - } - : {}), - model, - ...ctx.commonAttrs, - }, - }, - ctx.rootContext(), - ) - ctx.runSpans.set(runID, runSpan) - setBoundedMap(ctx.runSpanContexts, runID, runSpan.spanContext()) +/** + * Ensures session totals exist for a session id. OpenCode does not replay durable + * events, so a session created before the plugin subscribed (for example a + * pre-existing session resumed with `opencode run`) never emits `session.created`; + * the first event we observe for it initializes and counts the session lazily. + */ +export function ensureSession(sessionID: string, at: number, ctx: HandlerContext): SessionTotals { + const existing = ctx.tracing.sessionTotals.get(sessionID) + if (existing) return existing + countSession(sessionID, false, ctx) + const totals: SessionTotals = { + startMs: at, + tokens: 0, + cost: 0, + messages: 0, + agent: "unknown", + agentType: "primary", + } + setBoundedMap(ctx.tracing.sessionTotals, sessionID, totals) + return totals } -/** Increments the session counter, records start time, starts the root session span, and emits a `session.created` log event. */ -export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) { - const { id: sessionID, time, parentID } = e.properties.info - const createdAt = time.created - const isSubagent = !!parentID +/** Increments the session counter, records totals, and emits a `session.created` log event. */ +export function handleSessionCreated(e: EventOf<"session.created">, ctx: HandlerContext) { + const d = e.data + const isSubagent = !!d.parentID const agentType: SessionAgentType = isSubagent ? "subagent" : "primary" - if (isMetricEnabled("session.count", ctx)) { - ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent }) - } - setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown", agentType }) + const agent = d.agent ?? "unknown" - if (isTraceEnabled("session", ctx) && parentID) { - const sessionSpan = ctx.tracer.startSpan( - `${ctx.tracePrefix}session`, - { - startTime: createdAt, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT, - [SESSION_ID]: sessionID, - [AGENT_NAME]: "unknown", - "agent.type": agentType, - "session.is_subagent": isSubagent, - ...ctx.commonAttrs, - }, - }, - resolveSessionTraceContext(parentID, ctx), - ) - ctx.sessionSpans.set(sessionID, sessionSpan) - setBoundedMap(ctx.sessionSpanContexts, sessionID, sessionSpan.spanContext()) + countSession(d.sessionID, isSubagent, ctx) + if (isSubagent && isMetricEnabled("subtask.count", ctx)) { + ctx.instruments.subtaskCounter.add(1, { + ...ctx.commonAttrs, + "session.id": d.sessionID, + "agent.type": "subagent", + }) } + setBoundedMap(ctx.tracing.sessionTotals, d.sessionID, { + startMs: e.created, + tokens: ctx.tracing.sessionTotals.get(d.sessionID)?.tokens ?? 0, + cost: ctx.tracing.sessionTotals.get(d.sessionID)?.cost ?? 0, + messages: ctx.tracing.sessionTotals.get(d.sessionID)?.messages ?? 0, + agent, + agentType, + ...(d.parentID ? { parentID: d.parentID } : {}), + }) + ctx.emitLog({ severityNumber: SeverityNumber.INFO, severityText: "INFO", - timestamp: createdAt, + timestamp: e.created, observedTimestamp: Date.now(), body: "session.created", attributes: { "event.name": "session.created", - "session.id": sessionID, + "session.id": d.sessionID, is_subagent: isSubagent, - ...agentAttrs("unknown", agentType), + ...agentAttrs(agent, agentType), + ...(d.model ? { model: modelRef(d.model) } : {}), ...ctx.commonAttrs, }, }) - return ctx.log("info", "otel: session.created", { sessionID, createdAt, isSubagent }) + void ctx.log("info", "otel: session.created", { sessionID: d.sessionID, isSubagent }) } -function sweepSession(sessionID: string, ctx: HandlerContext) { - for (const [id, perm] of ctx.pendingPermissions) { - if (perm.sessionID === sessionID) ctx.pendingPermissions.delete(id) - } - for (const [key, span] of ctx.pendingToolSpans) { - if (span.sessionID === sessionID) { - span.span?.setStatus({ code: SpanStatusCode.ERROR, message: "session ended before tool completed" }) - span.span?.end() - ctx.pendingToolSpans.delete(key) - } - } - ctx.pendingRuns.delete(sessionID) - const msgPrefix = `${sessionID}:` - for (const [key, span] of ctx.messageSpans) { - if (key.startsWith(msgPrefix)) { - span.setStatus({ code: SpanStatusCode.ERROR, message: "session ended before message completed" }) - span.end() - ctx.messageSpans.delete(key) - } - } - for (const key of ctx.messageOutputs.keys()) { - if (key.startsWith(msgPrefix)) ctx.messageOutputs.delete(key) - } - for (const key of ctx.llmRequestContexts.keys()) { - if (key.startsWith(msgPrefix)) ctx.llmRequestContexts.delete(key) - } +/** Starts the root run span for a single execution (user turn), keyed by session id. */ +export function handleExecutionStarted(e: EventOf<"session.execution.started">, ctx: HandlerContext) { + const sessionID = e.data.sessionID + const totals = ensureSession(sessionID, e.created, ctx) + const pendingPrompt = ctx.tracing.pendingPrompts.get(sessionID) + + if (!isTraceEnabled("session", ctx)) return + + const isSubagent = totals?.agentType === "subagent" + const parentCtx = isSubagent && totals?.parentID + ? resolveRunContext(totals.parentID, ctx) + : ctx.rootContext() + const promptText = pendingPrompt?.text ?? "" + + const span = ctx.tracer.startSpan( + `${ctx.tracePrefix}session`, + { + startTime: e.created, + attributes: { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT, + [SESSION_ID]: sessionID, + [AGENT_NAME]: totals?.agent ?? "unknown", + "agent.type": totals?.agentType ?? "primary", + "session.is_subagent": isSubagent, + ...(promptText + ? { + [INPUT_VALUE]: promptText, + [INPUT_MIME_TYPE]: MimeType.TEXT, + [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), + } + : {}), + ...ctx.commonAttrs, + }, + }, + parentCtx, + ) + setBoundedMap(ctx.tracing.runSpans, sessionID, span) + setBoundedMap(ctx.tracing.runSpanContexts, sessionID, span.spanContext()) } -/** Emits a `session.idle` log event, records duration and session total histograms, ends the session span, and clears pending state. */ -export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { - const sessionID = e.properties.sessionID - const totals = ctx.sessionTotals.get(sessionID) - const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) - ctx.sessionTotals.delete(sessionID) - ctx.sessionDiffTotals.delete(sessionID) - sweepSession(sessionID, ctx) +/** Terminal execution events that end the root run span. */ +export type ExecutionOutcome = + | { type: "succeeded" } + | { type: "failed"; error: { type: string; message: string; status?: number } } + | { type: "interrupted"; reason: string } - const attrs = { ...ctx.commonAttrs, "session.id": sessionID } - let duration_ms: number | undefined +/** + * Ends the root run span for an execution with the given outcome and emits a + * `session.error` log event when the execution failed. + */ +export function handleExecutionEnded( + e: EventOf<"session.execution.succeeded"> | EventOf<"session.execution.failed"> | EventOf<"session.execution.interrupted">, + ctx: HandlerContext, + outcome: ExecutionOutcome, +) { + const sessionID = e.data.sessionID + const totals = ctx.tracing.sessionTotals.get(sessionID) - if (totals) { - duration_ms = Date.now() - totals.startMs - if (isMetricEnabled("session.duration", ctx)) { - ctx.instruments.sessionDurationHistogram.record(duration_ms, attrs) - } - if (isMetricEnabled("session.token.total", ctx)) { - ctx.instruments.sessionTokenGauge.record(totals.tokens, attrs) - } - if (isMetricEnabled("session.cost.total", ctx)) { - ctx.instruments.sessionCostGauge.record(totals.cost, attrs) - } - } + sweepExecution(sessionID, ctx) - const sessionSpan = ctx.sessionSpans.get(sessionID) - if (sessionSpan) { + const span = ctx.tracing.runSpans.get(sessionID) + if (span) { if (totals) { - sessionSpan.setAttributes({ + span.setAttributes({ [AGENT_NAME]: totals.agent, "agent.type": totals.agentType, "session.total_tokens": totals.tokens, @@ -193,26 +173,100 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { "session.total_messages": totals.messages, }) } - sessionSpan.setStatus({ code: SpanStatusCode.OK }) - sessionSpan.end() - ctx.sessionSpans.delete(sessionID) + if (outcome.type === "failed") { + const message = errorSummary(outcome.error) + span.setStatus({ code: SpanStatusCode.ERROR, message }) + span.setAttribute("error", message) + } else if (outcome.type === "interrupted") { + span.setAttribute("session.interrupted_reason", outcome.reason) + span.setStatus({ code: SpanStatusCode.OK }) + } else { + span.setStatus({ code: SpanStatusCode.OK }) + } + span.end(e.created) + ctx.tracing.runSpans.delete(sessionID) + ctx.tracing.runSpanContexts.delete(sessionID) } - const runID = ctx.activeRuns.get(sessionID) - if (runID) ctx.activeRuns.delete(sessionID) - const runSpan = runID ? ctx.runSpans.get(runID) : undefined - if (runSpan) { - if (totals) { - runSpan.setAttributes({ - [AGENT_NAME]: totals.agent, - "agent.type": totals.agentType, - "session.total_tokens": totals.tokens, - "session.total_cost_usd": totals.cost, - "session.total_messages": totals.messages, - }) + + if (outcome.type === "failed") { + const message = errorSummary(outcome.error) + ctx.emitLog({ + severityNumber: SeverityNumber.ERROR, + severityText: "ERROR", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "session.error", + attributes: { + "event.name": "session.error", + "session.id": sessionID, + error: message, + ...agentAttrs(totals?.agent ?? "unknown", totals?.agentType ?? "unknown"), + ...ctx.commonAttrs, + }, + }) + void ctx.log("error", "otel: session.error", { sessionID, error: message }) + } +} + +/** Handles `session.status`: counts retries and finalizes the session on idle. */ +export function handleSessionStatus(e: EventOf<"session.status">, ctx: HandlerContext) { + const { sessionID, status } = e.data + if (status.type === "retry") { + if (isMetricEnabled("retry.count", ctx)) { + ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) } - runSpan.setStatus({ code: SpanStatusCode.OK }) - runSpan.end() - ctx.runSpans.delete(runID!) + void ctx.log("debug", "otel: retry scheduled", { sessionID, attempt: status.attempt }) + return + } + if (status.type === "idle") finalizeSession(sessionID, ctx) +} + +/** Handles the deprecated `session.idle` event as an idle finalization signal. */ +export function handleSessionIdle(e: EventOf<"session.idle">, ctx: HandlerContext) { + finalizeSession(e.data.sessionID, ctx) +} + +/** + * Updates the running session totals from the authoritative cumulative usage + * sample emitted by opencode after each step. + */ +export function handleUsageUpdated(e: EventOf<"session.usage.updated">, ctx: HandlerContext) { + const { sessionID, cost, tokens } = e.data + const existing = ensureSession(sessionID, e.created, ctx) + const next: SessionTotals = { + ...existing, + tokens: totalTokens(tokens) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0), + cost, + } + setBoundedMap(ctx.tracing.sessionTotals, sessionID, next) +} + +/** + * Records session duration and total token/cost histograms and emits a + * `session.idle` log, then clears per-session state. No-ops when the session + * totals were already finalized, so a status-idle followed by a deprecated + * idle event does not double count. + */ +export function finalizeSession(sessionID: string, ctx: HandlerContext) { + const totals = ctx.tracing.sessionTotals.get(sessionID) + if (!totals) { + sweepExecution(sessionID, ctx) + return + } + ctx.tracing.sessionTotals.delete(sessionID) + ctx.tracing.activeLlm.delete(sessionID) + ctx.tracing.pendingPrompts.delete(sessionID) + + const attrs = { ...ctx.commonAttrs, "session.id": sessionID } + const durationMs = Date.now() - totals.startMs + if (isMetricEnabled("session.duration", ctx)) { + ctx.instruments.sessionDurationHistogram.record(durationMs, attrs) + } + if (isMetricEnabled("session.token.total", ctx)) { + ctx.instruments.sessionTokenHistogram.record(totals.tokens, attrs) + } + if (isMetricEnabled("session.cost.total", ctx)) { + ctx.instruments.sessionCostHistogram.record(totals.cost, attrs) } ctx.emitLog({ @@ -224,77 +278,44 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { attributes: { "event.name": "session.idle", "session.id": sessionID, - total_tokens: totals?.tokens ?? 0, - total_cost_usd: totals?.cost ?? 0, - total_messages: totals?.messages ?? 0, - ...agentAttrs(agentName, agentType), + total_tokens: totals.tokens, + total_cost_usd: totals.cost, + total_messages: totals.messages, + ...agentAttrs(totals.agent, totals.agentType), ...ctx.commonAttrs, }, }) - ctx.log("debug", "otel: session.idle", { + void ctx.log("debug", "otel: session.idle", { sessionID, - ...(totals ? { duration_ms, total_tokens: totals.tokens, total_cost_usd: totals.cost, total_messages: totals.messages } : {}), + duration_ms: durationMs, + total_tokens: totals.tokens, + total_cost_usd: totals.cost, + total_messages: totals.messages, }) } -/** Emits a `session.error` log event, ends the session span with error status, and clears any pending state for the session. */ -export function handleSessionError(e: EventSessionError, ctx: HandlerContext) { - const rawID = e.properties.sessionID - const sessionID = rawID ?? "unknown" - const error = errorSummary(e.properties.error) - const { agentName, agentType } = rawID ? getSessionAgentMeta(rawID, ctx) : { agentName: "unknown", agentType: "unknown" as const } - const totals = rawID ? ctx.sessionTotals.get(rawID) : undefined - if (rawID) { - ctx.sessionTotals.delete(rawID) - ctx.sessionDiffTotals.delete(rawID) +/** Ends and clears any dangling step/tool spans for a session execution. */ +function sweepExecution(sessionID: string, ctx: HandlerContext) { + for (const [callID, span] of ctx.tracing.toolSpans) { + if (ctx.tracing.toolMeta.get(callID)?.sessionID !== sessionID) continue + span.setStatus({ code: SpanStatusCode.ERROR, message: "session execution ended before tool completed" }) + span.end() + ctx.tracing.toolSpans.delete(callID) + ctx.tracing.toolSpanContexts.delete(callID) + ctx.tracing.toolMeta.delete(callID) } - sweepSession(sessionID, ctx) - - if (rawID) { - const sessionSpan = ctx.sessionSpans.get(rawID) - if (sessionSpan) { - if (totals) sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, "agent.type": totals.agentType }) - sessionSpan.setStatus({ code: SpanStatusCode.ERROR, message: error }) - sessionSpan.setAttribute("error", error) - sessionSpan.end() - ctx.sessionSpans.delete(rawID) - } - const runID = ctx.activeRuns.get(rawID) - if (runID) ctx.activeRuns.delete(rawID) - const runSpan = runID ? ctx.runSpans.get(runID) : undefined - if (runSpan) { - if (totals) runSpan.setAttributes({ [AGENT_NAME]: totals.agent, "agent.type": totals.agentType }) - runSpan.setStatus({ code: SpanStatusCode.ERROR, message: error }) - runSpan.setAttribute("error", error) - runSpan.end() - ctx.runSpans.delete(runID!) + for (const [messageID, meta] of ctx.tracing.stepMeta) { + if (meta.sessionID !== sessionID) continue + const span = ctx.tracing.stepSpans.get(messageID) + if (span) { + span.setStatus({ code: SpanStatusCode.ERROR, message: "session execution ended before step completed" }) + span.end() } + ctx.tracing.stepSpans.delete(messageID) + ctx.tracing.stepSpanContexts.delete(messageID) + ctx.tracing.stepMeta.delete(messageID) } - - ctx.emitLog({ - severityNumber: SeverityNumber.ERROR, - severityText: "ERROR", - timestamp: Date.now(), - observedTimestamp: Date.now(), - body: "session.error", - attributes: { - "event.name": "session.error", - "session.id": sessionID, - error, - ...agentAttrs(agentName, agentType), - ...ctx.commonAttrs, - }, - }) - ctx.log("error", "otel: session.error", { sessionID, error }) -} - -/** Increments the retry counter when the session enters a retry state. */ -export function handleSessionStatus(e: EventSessionStatus, ctx: HandlerContext) { - if (e.properties.status.type !== "retry") return - const { sessionID, status } = e.properties - const { attempt, message: retryMessage } = status - if (isMetricEnabled("retry.count", ctx)) { - ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) - ctx.log("debug", "otel: retry counter incremented", { sessionID, attempt, retryMessage }) + for (const [id, perm] of ctx.tracing.pendingPermissions) { + if (perm.sessionID === sessionID) ctx.tracing.pendingPermissions.delete(id) } } diff --git a/src/handlers/step.ts b/src/handlers/step.ts new file mode 100644 index 0000000..66d3d1e --- /dev/null +++ b/src/handlers/step.ts @@ -0,0 +1,279 @@ +import { SeverityNumber } from "@opentelemetry/api-logs" +import { SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { + AGENT_NAME, + INPUT_MIME_TYPE, + INPUT_VALUE, + LLM_COST_TOTAL, + LLM_INPUT_MESSAGES, + LLM_MODEL_NAME, + LLM_OUTPUT_MESSAGES, + LLM_PROVIDER, + LLM_SYSTEM, + LLM_TOKEN_COUNT_COMPLETION, + LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, + LLM_TOKEN_COUNT_PROMPT, + LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ, + LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE, + LLM_TOKEN_COUNT_TOTAL, + MimeType, + OpenInferenceSpanKind, + SemanticConventions, + SESSION_ID, +} from "@arizeai/openinference-semantic-conventions" +import type { EventOf, HandlerContext, SessionTotals } from "../types.ts" +import { ensureSession } from "./session.ts" +import { + agentAttrs, + errorSummary, + genAiProviderName, + isMetricEnabled, + isTraceEnabled, + resolveRunContext, + setBoundedMap, +} from "../util.ts" + +const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND +const LLM_FINISH_REASON = "llm.finish_reason" + +function billedTokens(tokens: { input: number; output: number; reasoning: number } | undefined): number { + return (tokens?.input ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0) +} + +function allTokens(tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } | undefined): number { + if (!tokens) return 0 + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + +function accumulateTotals( + sessionID: string, + tokens: number, + cost: number, + incrementMessages: boolean, + ctx: HandlerContext, +) { + const existing = ctx.tracing.sessionTotals.get(sessionID) + if (!existing) return + const next: SessionTotals = { + ...existing, + tokens: existing.tokens + tokens, + cost: existing.cost + cost, + messages: existing.messages + (incrementMessages ? 1 : 0), + } + setBoundedMap(ctx.tracing.sessionTotals, sessionID, next) +} + +/** Starts an LLM span for a step and records the model/agent metadata for the request. */ +export function handleStepStarted(e: EventOf<"session.step.started">, ctx: HandlerContext) { + const d = e.data + const totals = ensureSession(d.sessionID, e.created, ctx) + const agentType = totals.agentType + setBoundedMap(ctx.tracing.stepMeta, d.assistantMessageID, { + sessionID: d.sessionID, + agent: d.agent, + agentType, + modelID: d.model.id, + providerID: d.model.providerID, + }) + if (totals && totals.agent !== d.agent) { + setBoundedMap(ctx.tracing.sessionTotals, d.sessionID, { ...totals, agent: d.agent }) + } + + if (!isTraceEnabled("llm", ctx)) return + const promptText = ctx.tracing.pendingPrompts.get(d.sessionID)?.text + + const span = ctx.tracer.startSpan( + `${ctx.tracePrefix}llm`, + { + startTime: d.started ?? e.created, + kind: SpanKind.CLIENT, + attributes: { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, + [SESSION_ID]: d.sessionID, + [AGENT_NAME]: d.agent, + "agent.type": agentType, + [LLM_SYSTEM]: d.model.providerID, + [LLM_PROVIDER]: d.model.providerID, + "gen_ai.provider.name": genAiProviderName(d.model.providerID), + [LLM_MODEL_NAME]: d.model.id, + ...(promptText + ? { + [INPUT_VALUE]: promptText, + [INPUT_MIME_TYPE]: MimeType.TEXT, + [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), + } + : {}), + ...ctx.commonAttrs, + }, + }, + resolveRunContext(d.sessionID, ctx), + ) + setBoundedMap(ctx.tracing.stepSpans, d.assistantMessageID, span) + setBoundedMap(ctx.tracing.stepSpanContexts, d.assistantMessageID, span.spanContext()) + setBoundedMap(ctx.tracing.activeLlm, d.sessionID, { + agent: d.agent, + modelID: d.model.id, + providerID: d.model.providerID, + spanContext: span.spanContext(), + }) +} + +/** Records token/cost/cache metrics for a completed step and emits an `api_request` log. */ +export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerContext) { + const d = e.data + const meta = ctx.tracing.stepMeta.get(d.assistantMessageID) + const sessionTotals = ctx.tracing.sessionTotals.get(d.sessionID) + const agent = meta?.agent ?? sessionTotals?.agent ?? "unknown" + const agentType = meta?.agentType ?? sessionTotals?.agentType ?? "unknown" + const modelID = meta?.modelID ?? "unknown" + const providerID = meta?.providerID ?? "unknown" + + recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost, ctx) + const firstStep = !ctx.tracing.countedMessages.has(d.assistantMessageID) + if (firstStep) { + ctx.tracing.countedMessages.add(d.assistantMessageID) + if (isMetricEnabled("message.count", ctx)) { + ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": d.sessionID, model: modelID, agent }) + } + if (isMetricEnabled("model.usage", ctx)) { + ctx.instruments.modelUsageCounter.add(1, { + ...ctx.commonAttrs, + "session.id": d.sessionID, + model: modelID, + provider: providerID, + agent, + }) + } + } + accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost, firstStep, ctx) + + const span = ctx.tracing.stepSpans.get(d.assistantMessageID) + if (span) { + span.setAttributes({ + [AGENT_NAME]: agent, + "agent.type": agentType, + [LLM_TOKEN_COUNT_PROMPT]: d.tokens.input, + [LLM_TOKEN_COUNT_COMPLETION]: d.tokens.output, + [LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: d.tokens.reasoning, + [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: d.tokens.cache.read, + [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: d.tokens.cache.write, + [LLM_TOKEN_COUNT_TOTAL]: allTokens(d.tokens), + [LLM_FINISH_REASON]: d.finish, + [LLM_COST_TOTAL]: d.cost, + cost_usd: d.cost, + }) + span.setStatus({ code: SpanStatusCode.OK }) + span.end(e.created) + } + cleanupStep(d.assistantMessageID, d.sessionID, ctx) + + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "api_request", + attributes: { + "event.name": "api_request", + "session.id": d.sessionID, + model: modelID, + provider: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), + ...agentAttrs(agent, agentType), + cost_usd: d.cost, + input_tokens: d.tokens.input, + output_tokens: d.tokens.output, + reasoning_tokens: d.tokens.reasoning, + cache_read_tokens: d.tokens.cache.read, + cache_creation_tokens: d.tokens.cache.write, + ...ctx.commonAttrs, + }, + }) +} + +/** Records token/cost metrics for a failed step and emits an `api_error` log. */ +export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: HandlerContext) { + const d = e.data + const meta = ctx.tracing.stepMeta.get(d.assistantMessageID) + const sessionTotals = ctx.tracing.sessionTotals.get(d.sessionID) + const agent = meta?.agent ?? sessionTotals?.agent ?? "unknown" + const agentType = meta?.agentType ?? sessionTotals?.agentType ?? "unknown" + const modelID = meta?.modelID ?? "unknown" + const providerID = meta?.providerID ?? "unknown" + const error = errorSummary(d.error) + + if (d.tokens) recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost ?? 0, ctx) + if (!ctx.tracing.countedMessages.has(d.assistantMessageID)) { + ctx.tracing.countedMessages.add(d.assistantMessageID) + accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost ?? 0, true, ctx) + } else { + accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost ?? 0, false, ctx) + } + + const span = ctx.tracing.stepSpans.get(d.assistantMessageID) + if (span) { + span.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType, [LLM_FINISH_REASON]: d.finish ?? "error" }) + span.setStatus({ code: SpanStatusCode.ERROR, message: error }) + span.end(e.created) + } + cleanupStep(d.assistantMessageID, d.sessionID, ctx) + + ctx.emitLog({ + severityNumber: SeverityNumber.ERROR, + severityText: "ERROR", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "api_error", + attributes: { + "event.name": "api_error", + "session.id": d.sessionID, + model: modelID, + provider: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), + ...agentAttrs(agent, agentType), + error, + ...ctx.commonAttrs, + }, + }) +} + +function recordUsageMetrics( + sessionID: string, + modelID: string, + agent: string, + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } | undefined, + cost: number, + ctx: HandlerContext, +) { + if (!tokens) return + const base = { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent } + if (isMetricEnabled("token.usage", ctx)) { + const { tokenCounter } = ctx.instruments + tokenCounter.add(tokens.input, { ...base, type: "input" }) + tokenCounter.add(tokens.output, { ...base, type: "output" }) + tokenCounter.add(tokens.reasoning, { ...base, type: "reasoning" }) + tokenCounter.add(tokens.cache.read, { ...base, type: "cacheRead" }) + tokenCounter.add(tokens.cache.write, { ...base, type: "cacheCreation" }) + } + if (isMetricEnabled("cost.usage", ctx)) { + ctx.instruments.costCounter.add(cost, base) + } + if (isMetricEnabled("cache.count", ctx)) { + if (tokens.cache.read > 0) ctx.instruments.cacheCounter.add(1, { ...base, type: "cacheRead" }) + if (tokens.cache.write > 0) ctx.instruments.cacheCounter.add(1, { ...base, type: "cacheCreation" }) + } + void ctx.log("debug", "otel: step usage recorded", { + sessionID, + model: modelID, + agent, + billed: billedTokens(tokens), + cost_usd: cost, + }) +} + +function cleanupStep(assistantMessageID: string, sessionID: string, ctx: HandlerContext) { + ctx.tracing.stepSpans.delete(assistantMessageID) + ctx.tracing.stepSpanContexts.delete(assistantMessageID) + ctx.tracing.stepMeta.delete(assistantMessageID) + ctx.tracing.activeLlm.delete(sessionID) +} diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts new file mode 100644 index 0000000..2827b5e --- /dev/null +++ b/src/handlers/tool.ts @@ -0,0 +1,197 @@ +import { SeverityNumber } from "@opentelemetry/api-logs" +import { SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { + AGENT_NAME, + INPUT_MIME_TYPE, + INPUT_VALUE, + MimeType, + OpenInferenceSpanKind, + OUTPUT_MIME_TYPE, + OUTPUT_VALUE, + SemanticConventions, + SESSION_ID, + TOOL_ID, + TOOL_NAME, + TOOL_PARAMETERS, +} from "@arizeai/openinference-semantic-conventions" +import type { EventOf, HandlerContext } from "../types.ts" +import { + agentAttrs, + errorSummary, + getSessionAgentMeta, + isMetricEnabled, + isTraceEnabled, + resolveStepContext, + setBoundedMap, +} from "../util.ts" + +const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND +const GIT_COMMIT_RE = /\bgit\s+commit(?![-\w])/ +const SHELL_TOOL_RE = /(^|\.)(bash|shell)$/i + +type ToolContent = { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } + +/** Records the tool name and start time, and starts a child tool span of the owning step. */ +export function handleToolInputStarted(e: EventOf<"session.tool.input.started">, ctx: HandlerContext) { + const d = e.data + setBoundedMap(ctx.tracing.toolMeta, d.id, { + sessionID: d.sessionID, + assistantMessageID: d.assistantMessageID, + tool: d.name, + startMs: e.created, + }) + if (!isTraceEnabled("tool", ctx)) return + const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) + const span = ctx.tracer.startSpan( + `${ctx.tracePrefix}tool.${d.name}`, + { + startTime: e.created, + kind: SpanKind.INTERNAL, + attributes: { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, + [SESSION_ID]: d.sessionID, + [TOOL_ID]: d.id, + [TOOL_NAME]: d.name, + [AGENT_NAME]: agentName, + "agent.type": agentType, + ...ctx.commonAttrs, + }, + }, + resolveStepContext(d.sessionID, d.assistantMessageID, ctx), + ) + setBoundedMap(ctx.tracing.toolSpans, d.id, span) + setBoundedMap(ctx.tracing.toolSpanContexts, d.id, span.spanContext()) +} + +/** Attaches the tool input to the span and detects `git commit` invocations in shell tools. */ +export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: HandlerContext) { + const d = e.data + const meta = ctx.tracing.toolMeta.get(d.id) + const tool = meta?.tool ?? "unknown" + const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) + const inputJson = safeJson(d.input) + + const span = ctx.tracing.toolSpans.get(d.id) + if (span) { + span.setAttributes({ + [TOOL_PARAMETERS]: inputJson, + [INPUT_VALUE]: inputJson, + [INPUT_MIME_TYPE]: MimeType.JSON, + [AGENT_NAME]: agentName, + "agent.type": agentType, + }) + } + + if (SHELL_TOOL_RE.test(tool)) { + const command = typeof d.input["command"] === "string" ? d.input["command"] : inputJson + if (GIT_COMMIT_RE.test(command)) { + if (isMetricEnabled("commit.count", ctx)) { + ctx.instruments.commitCounter.add(1, { ...ctx.commonAttrs, "session.id": d.sessionID }) + } + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "commit", + attributes: { + "event.name": "commit", + "session.id": d.sessionID, + ...agentAttrs(agentName, agentType), + ...ctx.commonAttrs, + }, + }) + } + } +} + +/** Ends a successful tool call: records duration, sets output attributes, and emits `tool_result`. */ +export function handleToolSuccess(e: EventOf<"session.tool.success">, ctx: HandlerContext) { + const output = contentText(e.data.content) + finishTool(e.data.id, e.data.sessionID, e.created, true, output, undefined, ctx) +} + +/** Ends a failed tool call: records duration, sets error attributes, and emits `tool_result`. */ +export function handleToolFailed(e: EventOf<"session.tool.failed">, ctx: HandlerContext) { + const output = contentText(e.data.content) + finishTool(e.data.id, e.data.sessionID, e.created, false, output, errorSummary(e.data.error), ctx) +} + +function finishTool( + callID: string, + sessionID: string, + endMs: number, + success: boolean, + output: string, + error: string | undefined, + ctx: HandlerContext, +) { + const meta = ctx.tracing.toolMeta.get(callID) + ctx.tracing.toolMeta.delete(callID) + const tool = meta?.tool ?? "unknown" + const start = meta?.startMs ?? endMs + const durationMs = Math.max(0, endMs - start) + const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) + const sizeBytes = output ? Buffer.byteLength(output, "utf8") : 0 + + if (isMetricEnabled("tool.duration", ctx)) { + ctx.instruments.toolDurationHistogram.record(durationMs, { + ...ctx.commonAttrs, + "session.id": sessionID, + tool_name: tool, + success, + }) + } + + const span = ctx.tracing.toolSpans.get(callID) + if (span) { + span.setAttributes({ [AGENT_NAME]: agentName, "agent.type": agentType, "tool.success": success }) + if (output) { + span.setAttributes({ [OUTPUT_VALUE]: output, [OUTPUT_MIME_TYPE]: MimeType.TEXT }) + } + if (success) { + span.setAttribute("tool.result_size_bytes", sizeBytes) + span.setStatus({ code: SpanStatusCode.OK }) + } else { + span.setAttribute("tool.error", error ?? "unknown") + span.setStatus({ code: SpanStatusCode.ERROR, message: error ?? "unknown" }) + } + span.end(endMs) + } + ctx.tracing.toolSpans.delete(callID) + ctx.tracing.toolSpanContexts.delete(callID) + + ctx.emitLog({ + severityNumber: success ? SeverityNumber.INFO : SeverityNumber.ERROR, + severityText: success ? "INFO" : "ERROR", + timestamp: start, + observedTimestamp: Date.now(), + body: "tool_result", + attributes: { + "event.name": "tool_result", + "session.id": sessionID, + tool_name: tool, + ...agentAttrs(agentName, agentType), + success, + duration_ms: durationMs, + ...(success ? { tool_result_size_bytes: sizeBytes } : { error: error ?? "unknown" }), + ...ctx.commonAttrs, + }, + }) +} + +function contentText(content: readonly ToolContent[] | undefined): string { + if (!content) return "" + return content + .map((part) => (part.type === "text" ? part.text : part.name ?? part.uri)) + .filter(Boolean) + .join("\n") +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) ?? "" + } catch { + return "" + } +} diff --git a/src/index.ts b/src/index.ts index 194270d..b6bc9c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,366 +1,13 @@ -import type { Plugin } from "@opencode-ai/plugin" -import { SeverityNumber } from "@opentelemetry/api-logs" -import { logs } from "@opentelemetry/api-logs" -import { ROOT_CONTEXT, trace } from "@opentelemetry/api" -import { AGENT_NAME } from "@arizeai/openinference-semantic-conventions" -import pkg from "../package.json" with { type: "json" } -import type { - EventSessionCreated, - EventSessionIdle, - EventSessionError, - EventSessionStatus, - EventMessageUpdated, - EventMessagePartUpdated, - EventPermissionUpdated, - EventPermissionReplied, - EventSessionDiff, - EventCommandExecuted, -} from "@opencode-ai/sdk" -import { LEVELS, type Level, type HandlerContext } from "./types.ts" -import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, type OtelPluginOptions } from "./config.ts" -import { probeEndpoint } from "./probe.ts" -import { setupOtel, createInstruments, forceFlushOtel } from "./otel.ts" -import { remoteParentContext } from "./trace-context.ts" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus, handleRunStarted } from "./handlers/session.ts" -import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts" -import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts" -import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts" -import { handleChatHeaders } from "./handlers/chat-headers.ts" -import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "./util.ts" -import type { SessionTotals } from "./types.ts" - -const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown" +import type { Plugin } from "@opencode/plugin" +import { setup } from "./plugin.ts" /** - * OpenCode plugin that exports session telemetry via OpenTelemetry (OTLP over gRPC or HTTP/protobuf). - * Instruments metrics (sessions, tokens, cost, lines of code, commits, tool durations) - * and structured log events. All instrumentation is gated on `OPENCODE_ENABLE_TELEMETRY`. + * OpenCode V2 plugin definition. Exported as the package default so OpenCode V2 + * discovers it via its `id` and `setup` function. Requires OpenCode `>=2`. */ -export const OtelPlugin: Plugin = async ({ project, client, directory, worktree }, options) => { - const config = loadConfig(options as OtelPluginOptions) - const otlpHeadersHelper = resolveHelperPath(config.otlpHeadersHelper, directory, worktree) - let minLevel: Level = "info" - - const log: HandlerContext["log"] = async (level, message, extra) => { - if (LEVELS[level] < LEVELS[minLevel]) return - await client.app.log({ body: { service: "opencode-plugin-otel", level, message, extra } }) - } - - if (!config.enabled) { - await log("info", "telemetry disabled (set OPENCODE_ENABLE_TELEMETRY to enable)") - return {} - } - - await log("info", "starting up", { - version: PLUGIN_VERSION, - endpoint: config.endpoint, - protocol: config.protocol, - metricsInterval: config.metricsInterval, - logsInterval: config.logsInterval, - metricPrefix: config.metricPrefix, - headersHelperSet: !!config.otlpHeadersHelper, - }) - - await log("debug", "config loaded", { - headersSet: !!config.otlpHeaders, - headersHelperSet: !!config.otlpHeadersHelper, - resourceAttributesSet: !!config.resourceAttributes, - spanAttributesSet: !!config.spanAttributes, - }) - - const probe = await probeEndpoint(config.endpoint) - if (probe.ok) { - await log("info", "OTLP endpoint reachable", { endpoint: config.endpoint, ms: probe.ms }) - } else { - await log("warn", "OTLP endpoint unreachable — exports may fail", { - endpoint: config.endpoint, - error: probe.error, - }) - } - - const providers = await setupOtel( - config.endpoint, - config.protocol, - config.metricsInterval, - config.logsInterval, - PLUGIN_VERSION, - config.otlpHeaders, - otlpHeadersHelper, - ) - const { meterProvider, loggerProvider, tracerProvider } = providers - await log("info", "OTel SDK initialized") - - const instruments = createInstruments(config.metricPrefix) - const logger = logs.getLogger("com.opencode") - const emitLog: HandlerContext["emitLog"] = (record) => { - if (!config.logsEnabled) return - logger.emit(record) - } - const tracer = trace.getTracer("com.opencode") - const remoteContext = remoteParentContext(config.traceparent, config.tracestate) - if (config.traceparent && !remoteContext) { - await log("warn", "invalid OPENCODE_TRACEPARENT ignored", { traceparentLength: config.traceparent.length }) - } - const rootContext = remoteContext ? () => remoteContext : () => ROOT_CONTEXT - const pendingToolSpans = new Map() - const pendingPermissions = new Map() - const sessionTotals = new Map() - const sessionDiffTotals = new Map() - const runSpans = new Map() - const runSpanContexts = new Map() - const activeRuns = new Map() - const assistantRuns = new Map() - const pendingRuns = new Map() - const runInputs = new Map() - const sessionSpans = new Map() - const sessionSpanContexts = new Map() - const messageSpans = new Map() - const messageOutputs = new Map() - const llmRequestContexts = new Map() - const { disabledMetrics, disabledTraces } = config - const commonAttrs = { - ...parseAttributePairs(config.spanAttributes), - "project.id": project.id, - } as const - - if (disabledMetrics.size > 0) { - await log("info", "metrics disabled", { disabled: [...disabledMetrics] }) - } - - if (disabledTraces.size > 0) { - await log("info", "traces disabled", { disabled: [...disabledTraces] }) - } - - if (!config.logsEnabled) { - await log("info", "OTLP log events disabled") - } - - if (config.capturePromptInLogs) { - await log( - "info", - "prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events (spans always carry the prompt regardless)", - ) - } - - const ctx: HandlerContext = { - log, - emitLog, - instruments, - commonAttrs, - pendingToolSpans, - pendingPermissions, - sessionTotals, - sessionDiffTotals, - disabledMetrics, - disabledTraces, - tracer, - tracePrefix: config.metricPrefix, - rootContext, - runSpans, - runSpanContexts, - activeRuns, - assistantRuns, - pendingRuns, - runInputs, - sessionSpans, - sessionSpanContexts, - messageSpans, - messageOutputs, - llmRequestContexts, - tracePropagationProviders: config.tracePropagationProviders, - } - - let shuttingDown = false - - async function flushTelemetry(reason: string) { - if (shuttingDown) return - await forceFlushOtel(providers) - await log("debug", "otel: telemetry flushed", { reason }) - } - - async function shutdown() { - if (shuttingDown) return - shuttingDown = true - await forceFlushOtel(providers) - await Promise.allSettled([meterProvider.shutdown(), loggerProvider.shutdown(), tracerProvider.shutdown()]) - } - - process.on("SIGTERM", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) - process.on("SIGINT", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)) }) - process.on("beforeExit", () => { shutdown().catch(() => {}) }) - - const safe = ( - name: string, - fn: (...args: T) => Promise | void, - ): ((...args: T) => Promise) => - async (...args: T) => { - try { - await fn(...args) - } catch (err) { - await log("error", `otel: unhandled error in ${name}`, { - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, - }) - } - } - - return { - config: async (cfg) => { - if (cfg.logLevel) { - const next = resolveLogLevel(cfg.logLevel, minLevel) - if (next !== minLevel) { - minLevel = next - await log("info", `log level set to "${minLevel}"`) - } else if (cfg.logLevel.toLowerCase() !== minLevel) { - await log("warn", `unknown log level "${cfg.logLevel}", keeping "${minLevel}"`) - } - } - }, - - "chat.headers": safe("chat.headers", async (input, output) => { - handleChatHeaders(input, output, ctx) - }), - - "chat.message": safe("chat.message", async (input, output) => { - const agent = input.agent ?? "unknown" - const startTime = Date.now() - const existingTotals = sessionTotals.get(input.sessionID) - const nextTotals: SessionTotals = { - startMs: existingTotals?.startMs ?? startTime, - tokens: existingTotals?.tokens ?? 0, - cost: existingTotals?.cost ?? 0, - messages: existingTotals?.messages ?? 0, - agent, - agentType: existingTotals?.agentType ?? "primary", - } - setBoundedMap(sessionTotals, input.sessionID, nextTotals) - const { agentType } = getSessionAgentMeta(input.sessionID, ctx) - const sessionSpan = sessionSpans.get(input.sessionID) - if (sessionSpan) sessionSpan.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType }) - const promptText = output.parts.map((part) => { - switch (part.type) { - case "text": - return part.text - case "file": - return part.filename ?? part.url - case "agent": - return part.name - case "subtask": - return part.description - default: - return "" - } - }).filter(Boolean).join("\n") - if (!sessionSpan) { - const model = input.model ? `${input.model.providerID}/${input.model.modelID}` : "unknown" - if (input.messageID) { - handleRunStarted( - input.messageID, - input.sessionID, - agent, - promptText, - model, - startTime, - ctx, - ) - } else { - setBoundedMap(pendingRuns, input.sessionID, { - agent, - promptText, - model, - startTime, - }) - } - } - const promptLength = promptText.length - emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: startTime, - observedTimestamp: startTime, - body: "user_prompt", - attributes: { - "event.name": "user_prompt", - "session.id": input.sessionID, - ...agentAttrs(agent, agentType), - prompt_length: promptLength, - ...(config.capturePromptInLogs ? { prompt: promptText } : {}), - model: input.model - ? `${input.model.providerID}/${input.model.modelID}` - : "unknown", - ...commonAttrs, - }, - }) - }), +const plugin = { + id: "devtheops.otel", + setup, +} satisfies Plugin.Plugin - event: safe("event", async ({ event }) => { - switch (event.type) { - case "session.created": - await handleSessionCreated(event as EventSessionCreated, ctx) - break - case "session.idle": - handleSessionIdle(event as EventSessionIdle, ctx) - await flushTelemetry("session.idle") - break - case "session.error": - handleSessionError(event as EventSessionError, ctx) - await flushTelemetry("session.error") - break - case "session.status": - handleSessionStatus(event as EventSessionStatus, ctx) - break - case "session.diff": - handleSessionDiff(event as EventSessionDiff, ctx) - break - case "command.executed": - handleCommandExecuted(event as EventCommandExecuted, ctx) - break - case "permission.updated": - handlePermissionUpdated(event as EventPermissionUpdated, ctx) - break - case "permission.replied": - handlePermissionReplied(event as EventPermissionReplied, ctx) - break - case "message.updated": { - const msgEvt = event as EventMessageUpdated - const info = msgEvt.properties.info - if (info.role === "user") { - const pendingRun = pendingRuns.get(info.sessionID) - if (!sessionSpans.has(info.sessionID) && (pendingRun || activeRuns.get(info.sessionID) !== info.id)) { - handleRunStarted( - info.id, - info.sessionID, - pendingRun?.agent ?? info.agent, - pendingRun?.promptText ?? "", - pendingRun?.model ?? `${info.model.providerID}/${info.model.modelID}`, - pendingRun?.startTime ?? info.time.created, - ctx, - ) - } - break - } - if (info.role === "assistant" && !info.time?.completed) { - startMessageSpan( - info.sessionID, - info.id, - info.parentID, - info.modelID ?? "unknown", - info.providerID ?? "unknown", - info.time?.created ?? Date.now(), - ctx, - info.mode, - ) - } - await handleMessageUpdated(msgEvt, ctx) - if (info.role === "assistant" && info.time?.completed) { - await flushTelemetry("message.completed") - } - break - } - case "message.part.updated": - await handleMessagePartUpdated(event as EventMessagePartUpdated, ctx) - break - } - }), - } -} +export default plugin diff --git a/src/otel.ts b/src/otel.ts index 27a2bcf..6832b7e 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -160,14 +160,6 @@ export function createInstruments(prefix: string): Instruments { unit: "USD", description: "Cost of the opencode session in USD", }), - linesCounter: meter.createCounter(`${prefix}lines_of_code.count`, { - unit: "{line}", - description: "Gross positive churn of lines added/removed across a session. Emits the positive delta vs. the previous session.diff; negative deltas (cumulative shrinkage) are dropped, so sums do not reconcile to net after any revert. Use lines_of_code.total for the authoritative live cumulative.", - }), - linesTotalGauge: meter.createGauge(`${prefix}lines_of_code.total`, { - unit: "{line}", - description: "Authoritative live cumulative lines added/removed for the current session. Mirrors opencode's session.diff cumulative value on every event; tracks partial and full reverts faithfully.", - }), commitCounter: meter.createCounter(`${prefix}commit.count`, { unit: "{commit}", description: "Number of git commits created", @@ -188,13 +180,13 @@ export function createInstruments(prefix: string): Instruments { unit: "{message}", description: "Number of completed assistant messages per session", }), - sessionTokenGauge: meter.createHistogram(`${prefix}session.token.total`, { + sessionTokenHistogram: meter.createHistogram(`${prefix}session.token.total`, { unit: "tokens", - description: "Total tokens consumed per session, recorded as a histogram on session idle", + description: "Total tokens consumed per session, recorded as a histogram when an execution ends", }), - sessionCostGauge: meter.createHistogram(`${prefix}session.cost.total`, { + sessionCostHistogram: meter.createHistogram(`${prefix}session.cost.total`, { unit: "USD", - description: "Total cost per session in USD, recorded as a histogram on session idle", + description: "Total cost per session in USD, recorded as a histogram when an execution ends", advice: { explicitBucketBoundaries: [0.01, 0.05, 0.10, 0.25, 0.50, 1.00, 2.50, 5.00, 10.00, 25.00], }, diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..e6f881c --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,235 @@ +import { ROOT_CONTEXT } from "@opentelemetry/api" +import { SeverityNumber } from "@opentelemetry/api-logs" +import pkg from "../package.json" with { type: "json" } +import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, type OtelPluginOptions } from "./config.ts" +import { probeEndpoint } from "./probe.ts" +import { forceFlushOtel } from "./otel.ts" +import { remoteParentContext } from "./trace-context.ts" +import { acquireSharedOtel, acquireTracingState, flushSharedOtel, releaseSharedOtel } from "./state.ts" +import { LEVELS, type HandlerContext, type Level, type OpenCodeContext, type OpenCodeEvent } from "./types.ts" +import { agentAttrs, markSeen, setBoundedMap } from "./util.ts" +import { + handleExecutionEnded, + handleExecutionStarted, + handleSessionCreated, + handleSessionIdle, + handleSessionStatus, + handleUsageUpdated, +} from "./handlers/session.ts" +import { handleStepEnded, handleStepFailed, handleStepStarted } from "./handlers/step.ts" +import { handleToolCalled, handleToolFailed, handleToolInputStarted, handleToolSuccess } from "./handlers/tool.ts" +import { handlePermissionAsked, handlePermissionReplied } from "./handlers/permission.ts" +import { handleModelRequest } from "./handlers/chat-headers.ts" + +const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown" +const EXIT_HOOK_KEY = "__opencode_plugin_otel_exit_hook__" + +/** + * OpenCode V2 plugin entrypoint. Sets up the OTel SDK, subscribes to the granular + * V2 event stream (`session.*`), and emits spans, metrics, and log events mirroring + * the Claude Code monitoring signals. All instrumentation is gated on + * `OPENCODE_ENABLE_TELEMETRY` (or the `enabled` plugin option). + */ +export async function setup(ctx: OpenCodeContext): Promise<() => Promise> { + const config = loadConfig(ctx.options as OtelPluginOptions) + const minLevel: Level = resolveLogLevel(config.logLevel ?? "info", "info") + + const log: HandlerContext["log"] = async (level, message, extra) => { + if (LEVELS[level] < LEVELS[minLevel]) return + const line = `[opencode-plugin-otel] ${message}` + if (level === "error") console.error(line, extra ?? "") + else if (level === "warn") console.warn(line, extra ?? "") + else console.log(line, extra ?? "") + } + + if (!config.enabled) { + await log("info", "telemetry disabled (set OPENCODE_ENABLE_TELEMETRY or the enabled option to enable)") + return async () => {} + } + + config.otlpHeadersHelper = resolveHelperPath( + config.otlpHeadersHelper, + ctx.location.directory, + ctx.location.project.directory, + ) + + await log("info", "starting up", { + version: PLUGIN_VERSION, + endpoint: config.endpoint, + protocol: config.protocol, + metricsInterval: config.metricsInterval, + logsInterval: config.logsInterval, + metricPrefix: config.metricPrefix, + headersHelperSet: !!config.otlpHeadersHelper, + }) + + const probe = await probeEndpoint(config.endpoint) + if (probe.ok) { + await log("info", "OTLP endpoint reachable", { endpoint: config.endpoint, ms: probe.ms }) + } else { + await log("warn", "OTLP endpoint unreachable — exports may fail", { endpoint: config.endpoint, error: probe.error }) + } + + const shared = await acquireSharedOtel(config, PLUGIN_VERSION) + const tracing = acquireTracingState() + await log("info", "OTel SDK initialized") + + const g = globalThis as Record + if (!g[EXIT_HOOK_KEY]) { + g[EXIT_HOOK_KEY] = true + process.once("beforeExit", () => { + void flushSharedOtel() + }) + } + + const emitLog: HandlerContext["emitLog"] = (record) => { + if (!config.logsEnabled) return + shared.logger.emit(record) + } + + const remoteContext = remoteParentContext(config.traceparent, config.tracestate) + if (config.traceparent && !remoteContext) { + await log("warn", "invalid OPENCODE_TRACEPARENT ignored", { traceparentLength: config.traceparent.length }) + } + + const hctx: HandlerContext = { + log, + emitLog, + instruments: shared.instruments, + commonAttrs: { + ...parseAttributePairs(config.spanAttributes), + "project.id": ctx.location.project.id, + } as const, + disabledMetrics: config.disabledMetrics, + disabledTraces: config.disabledTraces, + tracer: shared.tracer, + tracePrefix: config.metricPrefix, + rootContext: remoteContext ? () => remoteContext : () => ROOT_CONTEXT, + tracing, + tracePropagationProviders: config.tracePropagationProviders, + } + + if (config.disabledMetrics.size > 0) await log("info", "metrics disabled", { disabled: [...config.disabledMetrics] }) + if (config.disabledTraces.size > 0) await log("info", "traces disabled", { disabled: [...config.disabledTraces] }) + if (!config.logsEnabled) await log("info", "OTLP log events disabled") + if (config.capturePromptInLogs) { + await log("info", "prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events") + } + + await ctx.session.hook("prompt", (event) => { + const prompt = event.prompt + const parts = [prompt.text] + for (const file of prompt.files ?? []) parts.push(file.name ?? file.uri) + for (const agent of prompt.agents ?? []) parts.push(agent.name) + for (const skill of prompt.skills ?? []) parts.push(skill.id) + const text = parts.filter(Boolean).join("\n") + setBoundedMap(tracing.pendingPrompts, event.sessionID, { text, startMs: Date.now() }) + const totals = tracing.sessionTotals.get(event.sessionID) + emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: Date.now(), + observedTimestamp: Date.now(), + body: "user_prompt", + attributes: { + "event.name": "user_prompt", + "session.id": event.sessionID, + ...(totals ? agentAttrs(totals.agent, totals.agentType) : {}), + prompt_length: text.length, + ...(config.capturePromptInLogs ? { prompt: text } : {}), + delivery: event.delivery, + ...hctx.commonAttrs, + }, + }) + }) + + await ctx.session.hook("model.request", (event) => { + handleModelRequest(event, hctx) + }) + + const dispatch = async (event: OpenCodeEvent): Promise => { + switch (event.type) { + case "session.created": + handleSessionCreated(event, hctx) + break + case "session.execution.started": + handleExecutionStarted(event, hctx) + break + case "session.execution.succeeded": + handleExecutionEnded(event, hctx, { type: "succeeded" }) + await forceFlushOtel(shared.providers) + break + case "session.execution.failed": + handleExecutionEnded(event, hctx, { type: "failed", error: event.data.error }) + await forceFlushOtel(shared.providers) + break + case "session.execution.interrupted": + handleExecutionEnded(event, hctx, { type: "interrupted", reason: event.data.reason }) + await forceFlushOtel(shared.providers) + break + case "session.status": + handleSessionStatus(event, hctx) + break + case "session.idle": + handleSessionIdle(event, hctx) + await forceFlushOtel(shared.providers) + break + case "session.usage.updated": + handleUsageUpdated(event, hctx) + break + case "session.step.started": + handleStepStarted(event, hctx) + break + case "session.step.ended": + handleStepEnded(event, hctx) + break + case "session.step.failed": + handleStepFailed(event, hctx) + break + case "session.tool.input.started": + handleToolInputStarted(event, hctx) + break + case "session.tool.called": + handleToolCalled(event, hctx) + break + case "session.tool.success": + handleToolSuccess(event, hctx) + break + case "session.tool.failed": + handleToolFailed(event, hctx) + break + case "permission.asked": + handlePermissionAsked(event, hctx) + break + case "permission.replied": + handlePermissionReplied(event, hctx) + break + default: + break + } + } + + const controller = new AbortController() + const running = (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (tracing.seenEvents.has(event.id)) continue + markSeen(tracing.seenEvents, event.id) + await dispatch(event) + } + } catch (err) { + if (!controller.signal.aborted) { + await log("error", "otel: event subscription ended", { + error: err instanceof Error ? err.message : String(err), + }) + } + } + })() + + return async () => { + controller.abort() + await running.catch(() => {}) + await forceFlushOtel(shared.providers) + await releaseSharedOtel() + } +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..fd978fb --- /dev/null +++ b/src/state.ts @@ -0,0 +1,103 @@ +import { trace } from "@opentelemetry/api" +import { logs } from "@opentelemetry/api-logs" +import { createInstruments, forceFlushOtel, setupOtel } from "./otel.ts" +import type { PluginConfig } from "./config.ts" +import type { SharedOtel, TracingState } from "./types.ts" + +const OTEL_KEY = "__opencode_plugin_otel_shared__" +const OTEL_PENDING_KEY = "__opencode_plugin_otel_pending__" +const TRACING_KEY = "__opencode_plugin_otel_tracing__" + +type Globals = Record + +function globals(): Globals { + return globalThis as unknown as Globals +} + +/** + * Returns the process-wide shared OTel SDK instance, creating it on first use. + * OpenCode may load one plugin instance per location, but `setGlobalMeterProvider` + * and friends may only be effectively registered once per process, so providers are + * shared and reference-counted rather than recreated per instance. + */ +export async function acquireSharedOtel(config: PluginConfig, version: string): Promise { + const g = globals() + const existing = g[OTEL_KEY] as SharedOtel | undefined + if (existing) { + existing.refs += 1 + return existing + } + + if (!g[OTEL_PENDING_KEY]) { + g[OTEL_PENDING_KEY] = (async (): Promise => { + const providers = await setupOtel( + config.endpoint, + config.protocol, + config.metricsInterval, + config.logsInterval, + version, + config.otlpHeaders, + config.otlpHeadersHelper, + ) + const shared: SharedOtel = { + providers, + instruments: createInstruments(config.metricPrefix), + logger: logs.getLogger("com.opencode"), + tracer: trace.getTracer("com.opencode"), + refs: 0, + } + g[OTEL_KEY] = shared + g[OTEL_PENDING_KEY] = undefined + return shared + })() + } + + const shared = await (g[OTEL_PENDING_KEY] as Promise) + shared.refs += 1 + return shared +} + +/** + * Flushes shared telemetry and releases one reference. The providers are never + * shut down: doing so poisons the OTel global registry for the rest of the + * process and would silently drop telemetry from later plugin instances. + */ +export async function releaseSharedOtel(): Promise { + const shared = globals()[OTEL_KEY] as SharedOtel | undefined + if (!shared) return + shared.refs = Math.max(0, shared.refs - 1) + await forceFlushOtel(shared.providers) +} + +/** Flushes shared telemetry without releasing a reference (best-effort, e.g. on process exit). */ +export async function flushSharedOtel(): Promise { + const shared = globals()[OTEL_KEY] as SharedOtel | undefined + if (shared) await forceFlushOtel(shared.providers) +} + +/** Returns the process-wide tracing correlation state, creating it on first use. */ +export function acquireTracingState(): TracingState { + const g = globals() + let state = g[TRACING_KEY] as TracingState | undefined + if (!state) { + state = { + seenEvents: new Set(), + runSpans: new Map(), + runSpanContexts: new Map(), + stepSpans: new Map(), + stepSpanContexts: new Map(), + toolSpans: new Map(), + toolSpanContexts: new Map(), + toolMeta: new Map(), + stepMeta: new Map(), + sessionTotals: new Map(), + countedSessions: new Set(), + countedMessages: new Set(), + pendingPrompts: new Map(), + pendingPermissions: new Map(), + activeLlm: new Map(), + } + g[TRACING_KEY] = state + } + return state +} diff --git a/src/types.ts b/src/types.ts index da0e816..219e0fc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ -import type { Context, Counter, Gauge, Histogram, Span, SpanContext, Tracer } from "@opentelemetry/api" -import type { LogRecord } from "@opentelemetry/api-logs" +import type { Context, Counter, Histogram, Span, SpanContext, Tracer } from "@opentelemetry/api" +import type { LogRecord, Logger } from "@opentelemetry/api-logs" +import type { Plugin } from "@opencode/plugin" +import type { OtelProviders } from "./otel.ts" /** Numeric priority map for log levels; higher value = higher severity. */ export const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const @@ -7,10 +9,24 @@ export const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const /** Union of supported log level names. */ export type Level = keyof typeof LEVELS -/** Maximum number of entries kept in `pendingToolSpans` and `pendingPermissions` maps. */ +/** Maximum number of entries kept in bounded correlation maps. */ export const MAX_PENDING = 500 -/** Structured logger forwarded to the opencode `client.app.log` API. */ +/** Maximum number of event ids retained for cross-instance de-duplication. */ +export const MAX_SEEN_EVENTS = 10_000 + +/** The OpenCode V2 plugin context passed to `setup`. */ +export type OpenCodeContext = Plugin.Context + +type SubscribeResult = ReturnType + +/** A single OpenCode V2 server event, as yielded by `ctx.event.subscribe()`. */ +export type OpenCodeEvent = SubscribeResult extends AsyncIterable ? E : never + +/** Narrows {@link OpenCodeEvent} to a single `type` discriminant. */ +export type EventOf = Extract + +/** Structured logger for plugin diagnostics, forwarded to the server console. */ export type PluginLogger = ( level: Level, message: string, @@ -20,19 +36,17 @@ export type PluginLogger = ( /** OTel attributes common to every emitted span, log, and metric. */ export type CommonAttrs = Readonly> -/** In-flight tool execution tracked between `running` and `completed`/`error` part updates. */ -export type PendingToolSpan = { - tool: string +/** In-flight permission prompt tracked between `permission.asked` and `permission.replied`. */ +export type PendingPermission = { + action: string + resources: string[] sessionID: string - startMs: number - span?: Span } -/** Permission prompt tracked between `permission.updated` and `permission.replied`. */ -export type PendingPermission = { - type: string - title: string - sessionID: string +/** Prompt text captured on the `prompt` hook and attached to the next run span. */ +export type PendingPrompt = { + text: string + startMs: number } /** OTel metric instruments created once at plugin startup and shared via `HandlerContext`. */ @@ -40,15 +54,13 @@ export type Instruments = { sessionCounter: Counter tokenCounter: Counter costCounter: Counter - linesCounter: Counter - linesTotalGauge: Gauge commitCounter: Counter toolDurationHistogram: Histogram cacheCounter: Counter sessionDurationHistogram: Histogram messageCounter: Counter - sessionTokenGauge: Histogram - sessionCostGauge: Histogram + sessionTokenHistogram: Histogram + sessionCostHistogram: Histogram modelUsageCounter: Counter retryCounter: Counter subtaskCounter: Counter @@ -57,7 +69,7 @@ export type Instruments = { /** Session role emitted by opencode: either the primary/root agent or a spawned subagent. */ export type SessionAgentType = "primary" | "subagent" -/** Accumulated per-session totals used for gauge snapshots on session.idle. */ +/** Accumulated per-session totals used for gauge snapshots at execution end. */ export type SessionTotals = { startMs: number tokens: number @@ -65,50 +77,81 @@ export type SessionTotals = { messages: number agent: string agentType: SessionAgentType + parentID?: string } -/** Pending root-run metadata captured from `chat.message` until the user message ID is known. */ -export type PendingRun = { +/** Model/agent metadata for an in-flight LLM step, keyed by assistant message ID. */ +export type StepMeta = { + sessionID: string agent: string - promptText: string - model: string - startTime: number + agentType: SessionAgentType | "unknown" + modelID: string + providerID: string } -/** Live LLM request span metadata used by the outbound header hook. */ +/** Metadata for an in-flight tool call keyed by tool call id. */ +export type ToolMeta = { + sessionID: string + assistantMessageID: string + tool: string + startMs: number +} + +/** Live LLM request metadata used by the `model.request` trace-propagation hook. */ export type LlmRequestContext = { - messageID: string agent: string modelID: string providerID: string spanContext: SpanContext } +/** + * Per-process shared OTel SDK instance. OpenCode loads a plugin instance per + * location but the OTel global providers may only be registered once, so the + * providers, instruments, logger, and tracer are shared via `globalThis`. + */ +export type SharedOtel = { + providers: OtelProviders + instruments: Instruments + logger: Logger + tracer: Tracer + refs: number +} + +/** + * Per-process tracing correlation state. Span ids are unique across the + * process, so a single shared state keeps parent lookups consistent when + * multiple plugin instances observe the same stream. + */ +export type TracingState = { + seenEvents: Set + runSpans: Map + runSpanContexts: Map + stepSpans: Map + stepSpanContexts: Map + toolSpans: Map + toolSpanContexts: Map + toolMeta: Map + stepMeta: Map + sessionTotals: Map + countedSessions: Set + countedMessages: Set + pendingPrompts: Map + pendingPermissions: Map + activeLlm: Map +} + /** Shared context threaded through every event handler. */ export type HandlerContext = { log: PluginLogger emitLog: (record: LogRecord) => void instruments: Instruments commonAttrs: CommonAttrs - pendingToolSpans: Map - pendingPermissions: Map - sessionTotals: Map - sessionDiffTotals: Map disabledMetrics: Set disabledTraces: Set tracer: Tracer tracePrefix: string rootContext: () => Context - runSpans: Map - runSpanContexts: Map - activeRuns: Map - assistantRuns: Map - pendingRuns: Map - runInputs: Map - sessionSpans: Map - sessionSpanContexts: Map - messageSpans: Map - messageOutputs: Map - llmRequestContexts: Map + tracing: TracingState tracePropagationProviders: Set } diff --git a/src/util.ts b/src/util.ts index 27958a0..e8590f3 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,5 @@ -import { trace } from "@opentelemetry/api" -import { MAX_PENDING } from "./types.ts" -import type { HandlerContext, SessionAgentType } from "./types.ts" +import { trace, type Context } from "@opentelemetry/api" +import { MAX_PENDING, type HandlerContext, type SessionAgentType, type TracingState } from "./types.ts" const GEN_AI_PROVIDER_NAMES: Readonly> = { "amazon-bedrock": "aws.bedrock", @@ -13,13 +12,24 @@ const GEN_AI_PROVIDER_NAMES: Readonly> = { xai: "x_ai", } -/** Returns a human-readable summary string from an opencode error object. */ -export function errorSummary(err: { name: string; data?: unknown } | undefined): string { +/** A structured error as emitted by the OpenCode V2 event stream. */ +export type StructuredError = { type: string; message: string; status?: number } + +/** Token counts as emitted by the OpenCode V2 event stream. */ +export type TokenInfo = { + input: number + output: number + reasoning: number + cache: { read: number; write: number } +} + +/** A `{ providerID, id }` model reference as emitted by the OpenCode V2 event stream. */ +export type ModelRef = { providerID: string; id: string; variant?: string } + +/** Returns a human-readable summary string from a structured error payload. */ +export function errorSummary(err: StructuredError | undefined): string { if (!err) return "unknown" - if (err.data && typeof err.data === "object" && "message" in err.data) { - return `${err.name}: ${(err.data as { message: string }).message}` - } - return err.name + return `${err.type}: ${err.message}` } /** Returns the canonical OTel GenAI provider name, preserving unknown provider IDs. */ @@ -27,6 +37,17 @@ export function genAiProviderName(providerID: string): string { return GEN_AI_PROVIDER_NAMES[providerID] ?? providerID } +/** Sums billed tokens for a usage sample, excluding cache reads/writes. */ +export function totalTokens(tokens: TokenInfo | undefined): number { + if (!tokens) return 0 + return (tokens.input ?? 0) + (tokens.output ?? 0) + (tokens.reasoning ?? 0) +} + +/** Formats a model reference as `provider/id`, defaulting to `unknown`. */ +export function modelRef(model: ModelRef | undefined): string { + return model ? `${model.providerID}/${model.id}` : "unknown" +} + /** * Inserts a key/value pair into `map`, evicting the oldest entry first when the map * has reached `MAX_PENDING` capacity to prevent unbounded memory growth. @@ -39,33 +60,13 @@ export function setBoundedMap(map: Map, key: K, value: V) { map.set(key, value) } -/** Resolves a root-run context from the live span first, then from the retained ended span context. */ -export function resolveRunTraceContext(runID: string, ctx: Pick) { - const baseCtx = ctx.rootContext() - const runSpan = ctx.runSpans.get(runID) - if (runSpan) return trace.setSpan(baseCtx, runSpan) - const runSpanContext = ctx.runSpanContexts.get(runID) - return runSpanContext ? trace.setSpanContext(baseCtx, runSpanContext) : baseCtx -} - -/** Resolves the best available trace parent for a session event or message/tool child span. */ -export function resolveSessionTraceContext( - sessionID: string, - ctx: HandlerContext, - input?: { assistantMessageID?: string; runID?: string }, -) { - const baseCtx = ctx.rootContext() - const sessionSpan = ctx.sessionSpans.get(sessionID) - if (sessionSpan) return trace.setSpan(baseCtx, sessionSpan) - const sessionSpanContext = ctx.sessionSpanContexts.get(sessionID) - if (sessionSpanContext) return trace.setSpanContext(baseCtx, sessionSpanContext) - if (input?.runID) return resolveRunTraceContext(input.runID, ctx) - const assistantRunID = input?.assistantMessageID - ? ctx.assistantRuns.get(input.assistantMessageID) - : undefined - if (assistantRunID) return resolveRunTraceContext(assistantRunID, ctx) - const activeRunID = ctx.activeRuns.get(sessionID) - return activeRunID ? resolveRunTraceContext(activeRunID, ctx) : baseCtx +/** Records an event id for de-duplication, evicting the oldest entry when at capacity. */ +export function markSeen(seen: TracingState["seenEvents"], id: string): void { + if (!seen.has(id) && seen.size >= MAX_PENDING * 20) { + const [first] = seen.values() + if (first !== undefined) seen.delete(first) + } + seen.add(id) } /** @@ -84,46 +85,41 @@ export function isTraceEnabled(name: string, ctx: { disabledTraces: Set return !ctx.disabledTraces.has(name) } -/** - * Accumulates token and cost totals for a session, and increments the message count. - * Uses `setBoundedMap` to produce a new object rather than mutating in-place. - * No-ops silently if the session was not previously registered via `handleSessionCreated`. - */ -export function accumulateSessionTotals( - sessionID: string, - tokens: number, - cost: number, - ctx: HandlerContext, -) { - const existing = ctx.sessionTotals.get(sessionID) - if (!existing) return - setBoundedMap(ctx.sessionTotals, sessionID, { - startMs: existing.startMs, - tokens: existing.tokens + tokens, - cost: existing.cost + cost, - messages: existing.messages + 1, - agent: existing.agent, - agentType: existing.agentType, - }) +/** Builds a consistent agent attribute set for OTLP logs, metrics, and spans. */ +export function agentAttrs(agentName: string, agentType: SessionAgentType | "unknown") { + return { + agent: agentName, + "agent.name": agentName, + "agent.type": agentType, + } as const } -/** Returns the current session-scoped agent name/type, defaulting to `unknown` when unavailable. */ +/** Resolves the trace context for a run span, falling back to the configured root context. */ +export function resolveRunContext(sessionID: string, ctx: HandlerContext): Context { + const base = ctx.rootContext() + const span = ctx.tracing.runSpans.get(sessionID) + if (span) return trace.setSpan(base, span) + const spanContext = ctx.tracing.runSpanContexts.get(sessionID) + return spanContext ? trace.setSpanContext(base, spanContext) : base +} + +/** Resolves the trace context for a step span, falling back to its run context. */ +export function resolveStepContext(sessionID: string, assistantMessageID: string, ctx: HandlerContext): Context { + const parent = resolveRunContext(sessionID, ctx) + const span = ctx.tracing.stepSpans.get(assistantMessageID) + if (span) return trace.setSpan(parent, span) + const spanContext = ctx.tracing.stepSpanContexts.get(assistantMessageID) + return spanContext ? trace.setSpanContext(parent, spanContext) : parent +} + +/** Resolves the current session-scoped agent name/type, defaulting to `unknown` when unavailable. */ export function getSessionAgentMeta( sessionID: string, - ctx: Pick, + ctx: HandlerContext, ): { agentName: string; agentType: SessionAgentType | "unknown" } { - const totals = ctx.sessionTotals.get(sessionID) + const totals = ctx.tracing.sessionTotals.get(sessionID) return { agentName: totals?.agent ?? "unknown", agentType: totals?.agentType ?? "unknown", } } - -/** Builds a consistent agent attribute set for OTLP logs, metrics, and spans. */ -export function agentAttrs(agentName: string, agentType: SessionAgentType | "unknown") { - return { - agent: agentName, - "agent.name": agentName, - "agent.type": agentType, - } as const -} diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 024b1c6..3da268d 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -1,163 +1,37 @@ -import { describe, expect, test } from "bun:test" -import { createTraceState } from "@opentelemetry/api" -import { handleChatHeaders } from "../src/handlers/chat-headers.ts" +import { describe, test, expect } from "bun:test" +import { handleModelRequest } from "../src/handlers/chat-headers.ts" import { makeCtx } from "./helpers.ts" -function makeInput(overrides: { agent?: string; modelID?: string; providerID?: string; messageID?: string } = {}) { - const providerID = overrides.providerID ?? "company-litellm" - return { - sessionID: "ses_1", - agent: overrides.agent ?? "build", - model: { id: overrides.modelID ?? "claude", providerID } as any, - provider: { source: "config", info: { id: providerID }, options: {} } as any, - message: { id: overrides.messageID ?? "user_1" } as any, - } +const SPAN_CONTEXT = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, } -function seedRequest(ctx: ReturnType["ctx"], overrides: { agent?: string; modelID?: string; providerID?: string } = {}) { - ctx.llmRequestContexts.set("ses_1:user_1", [ - { - messageID: "msg_1", - agent: overrides.agent ?? "build", - modelID: overrides.modelID ?? "claude", - providerID: overrides.providerID ?? "company-litellm", - spanContext: { - traceId: "0af7651916cd43dd8448eb211c80319c", - spanId: "b7ad6b7169203331", - traceFlags: 1, - }, - }, - ]) -} - -describe("handleChatHeaders", () => { - test("injects traceparent for an enabled provider", () => { - const { ctx } = makeCtx() - 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") - }) - - test("injects tracestate when present", () => { - const { ctx } = makeCtx() - 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("vendor=value") - }) - - test("supports an explicit wildcard", () => { - const { ctx } = makeCtx() - ctx.tracePropagationProviders.add("*") - seedRequest(ctx) - const output = { headers: {} as Record } - - handleChatHeaders(makeInput(), output, ctx) - - expect(output.headers.traceparent).toBeDefined() - }) - - test("does not inject for an unconfigured provider", () => { - const { ctx } = makeCtx() - seedRequest(ctx) - const output = { headers: {} as Record } - - handleChatHeaders(makeInput(), output, ctx) - - expect(output.headers).toEqual({}) - }) - - test("does not inject without a matching live request", () => { - const { ctx } = makeCtx() - ctx.tracePropagationProviders.add("company-litellm") - const output = { headers: {} as Record } - - handleChatHeaders(makeInput(), output, ctx) - - expect(output.headers).toEqual({}) - }) - - test("does not attach a concurrent title request to the main span", () => { - const { ctx } = makeCtx() - ctx.tracePropagationProviders.add("company-litellm") - seedRequest(ctx) - const output = { headers: {} as Record } - - handleChatHeaders(makeInput({ agent: "title" }), output, ctx) - - expect(output.headers).toEqual({}) - }) - - test("selects each concurrently live request by agent, model, and provider", () => { +describe("handleModelRequest", () => { + test("injects traceparent for a configured provider", () => { const { ctx } = makeCtx() - ctx.tracePropagationProviders.add("*") - seedRequest(ctx) - ctx.llmRequestContexts.get("ses_1:user_1")!.push({ - messageID: "msg_2", - agent: "review", - modelID: "gpt-5", - providerID: "company-openai", - spanContext: { - traceId: "4bf92f3577b34da6a3ce929d0e0e4736", - spanId: "00f067aa0ba902b7", - traceFlags: 1, - }, - }) - const buildOutput = { headers: {} as Record } - const reviewOutput = { headers: {} as Record } - - handleChatHeaders(makeInput(), buildOutput, ctx) - handleChatHeaders( - makeInput({ agent: "review", modelID: "gpt-5", providerID: "company-openai" }), - reviewOutput, - ctx, - ) - - expect(buildOutput.headers.traceparent).toContain("0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331") - expect(reviewOutput.headers.traceparent).toContain("4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7") + ctx.tracePropagationProviders.add("litellm") + ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "litellm", spanContext: SPAN_CONTEXT }) + const headers: Record = {} + handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "litellm", id: "m" }, kind: "primary", headers }, ctx) + expect(headers["traceparent"]).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") }) - test("selects the newest live request when metadata is identical", () => { + test("does nothing when the provider is not configured", () => { const { ctx } = makeCtx() - ctx.tracePropagationProviders.add("company-litellm") - seedRequest(ctx) - ctx.llmRequestContexts.get("ses_1:user_1")!.push({ - messageID: "msg_2", - agent: "build", - modelID: "claude", - providerID: "company-litellm", - spanContext: { - traceId: "4bf92f3577b34da6a3ce929d0e0e4736", - spanId: "00f067aa0ba902b7", - traceFlags: 1, - }, - }) - const output = { headers: {} as Record } - - handleChatHeaders(makeInput(), output, ctx) - - expect(output.headers.traceparent).toBe("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "openai", spanContext: SPAN_CONTEXT }) + const headers: Record = {} + handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "openai", id: "m" }, kind: "primary", headers }, ctx) + expect(headers["traceparent"]).toBeUndefined() }) - test("requires matching model and provider metadata", () => { + test("supports the wildcard provider", () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("*") - seedRequest(ctx) - const modelOutput = { headers: {} as Record } - const providerOutput = { headers: {} as Record } - - handleChatHeaders(makeInput({ modelID: "other" }), modelOutput, ctx) - handleChatHeaders(makeInput({ providerID: "other" }), providerOutput, ctx) - - expect(modelOutput.headers).toEqual({}) - expect(providerOutput.headers).toEqual({}) + ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "vllm", spanContext: SPAN_CONTEXT }) + const headers: Record = {} + handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) + expect(headers["traceparent"]).toBeDefined() }) }) diff --git a/tests/handlers/activity.test.ts b/tests/handlers/activity.test.ts deleted file mode 100644 index 077fce3..0000000 --- a/tests/handlers/activity.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { handleSessionDiff, handleCommandExecuted } from "../../src/handlers/activity.ts" -import { makeCtx } from "../helpers.ts" -import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" - -function makeSessionDiff( - sessionID: string, - diffs: Array<{ file: string; additions: number; deletions: number }>, -): EventSessionDiff { - return { - type: "session.diff", - properties: { - sessionID, - diff: diffs.map((d) => ({ before: "", after: "", additions: d.additions, deletions: d.deletions, file: d.file })), - }, - } as unknown as EventSessionDiff -} - -function makeCommandExecuted(name: string, args: string, sessionID = "ses_1"): EventCommandExecuted { - return { - type: "command.executed", - properties: { name, arguments: args, sessionID, messageID: "msg_1" }, - } as unknown as EventCommandExecuted -} - -describe("handleSessionDiff", () => { - test("increments linesCounter for additions", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "foo.ts", additions: 10, deletions: 0 }]), ctx) - expect(counters.lines.calls).toHaveLength(1) - expect(counters.lines.calls.at(0)!.value).toBe(10) - expect(counters.lines.calls.at(0)!.attrs["type"]).toBe("added") - }) - - test("increments linesCounter for deletions", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "foo.ts", additions: 0, deletions: 5 }]), ctx) - expect(counters.lines.calls).toHaveLength(1) - expect(counters.lines.calls.at(0)!.value).toBe(5) - expect(counters.lines.calls.at(0)!.attrs["type"]).toBe("removed") - }) - - test("increments both added and removed for mixed diffs", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "foo.ts", additions: 8, deletions: 3 }]), ctx) - expect(counters.lines.calls).toHaveLength(2) - const types = counters.lines.calls.map((c) => c.attrs["type"]) - expect(types).toContain("added") - expect(types).toContain("removed") - }) - - test("handles multiple files", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff( - makeSessionDiff("ses_1", [ - { file: "a.ts", additions: 5, deletions: 0 }, - { file: "b.ts", additions: 3, deletions: 2 }, - ]), - ctx, - ) - const totalAdded = counters.lines.calls - .filter((c) => c.attrs["type"] === "added") - .reduce((sum, c) => sum + c.value, 0) - expect(totalAdded).toBe(8) - }) - - test("skips zero additions", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "foo.ts", additions: 0, deletions: 0 }]), ctx) - expect(counters.lines.calls).toHaveLength(0) - }) - - test("linesCounter emits only positive deltas across multiple events", () => { - const { ctx, counters } = makeCtx() - // opencode publishes session.diff with the CUMULATIVE session total every event. - // Cumulative sequence: 4, 9, 9, 11. Expected deltas: 4, 5, 0 (skipped), 2. - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 4, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 9, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 9, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 11, deletions: 0 }]), ctx) - const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value) - expect(added).toEqual([4, 5, 2]) - expect(added.reduce((a, b) => a + b, 0)).toBe(11) // net, not 4+9+9+11=33 - }) - - test("linesCounter skips negative deltas (revert-to-baseline)", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 0, deletions: 0 }]), ctx) - const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value) - expect(added).toEqual([5]) - }) - - test("linesCounter is gross-only across a partial revert (additions shrink, deletions grow)", () => { - // Cumulative goes {additions:10, deletions:0} -> {additions:5, deletions:5}. - // Delta is {added:-5, removed:+5}. Negative added is skipped; positive removed - // is emitted. Counter ends at added=10, removed=5 while the authoritative live - // cumulative is added=5, removed=5 — the counter is GROSS, not net. Live - // cumulative state is surfaced via linesTotalGauge (see next test). - const { ctx, counters, gauges } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 10, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 5 }]), ctx) - - const added = counters.lines.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value) - const removed = counters.lines.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value) - expect(added).toEqual([10]) - expect(removed).toEqual([5]) - - const gaugeAdded = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value) - const gaugeRemoved = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value) - expect(gaugeAdded).toEqual([10, 5]) - expect(gaugeRemoved).toEqual([0, 5]) - }) - - test("linesTotalGauge records cumulative totals, including zero after revert", () => { - const { ctx, gauges } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 2 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 0, deletions: 0 }]), ctx) - const added = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "added").map((c) => c.value) - const removed = gauges.linesTotal.calls.filter((c) => c.attrs["type"] === "removed").map((c) => c.value) - expect(added).toEqual([5, 0]) - expect(removed).toEqual([2, 0]) - }) - - test("tracks deltas independently per session", () => { - const { ctx, counters } = makeCtx() - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 3, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_2", [{ file: "b.ts", additions: 7, deletions: 0 }]), ctx) - handleSessionDiff(makeSessionDiff("ses_1", [{ file: "a.ts", additions: 5, deletions: 0 }]), ctx) - const ses1 = counters.lines.calls.filter((c) => c.attrs["session.id"] === "ses_1").map((c) => c.value) - const ses2 = counters.lines.calls.filter((c) => c.attrs["session.id"] === "ses_2").map((c) => c.value) - expect(ses1).toEqual([3, 2]) - expect(ses2).toEqual([7]) - }) -}) - -describe("handleCommandExecuted", () => { - test("increments commit counter for git commit", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("bash", 'git commit -m "feat: add thing"'), ctx) - expect(counters.commit.calls).toHaveLength(1) - }) - - test("emits commit log record", () => { - const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - handleCommandExecuted(makeCommandExecuted("bash", "git commit -m 'fix: bug'"), ctx) - expect(logger.records).toHaveLength(1) - expect(logger.records.at(0)!.body).toBe("commit") - expect(logger.records.at(0)!.attributes?.["session.id"]).toBe("ses_1") - expect(logger.records.at(0)!.attributes?.["agent.name"]).toBe("build") - expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") - }) - - test("ignores non-bash commands", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("python", "git commit -m foo"), ctx) - expect(counters.commit.calls).toHaveLength(0) - }) - - test("ignores bash commands without git commit", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("bash", "npm install"), ctx) - expect(counters.commit.calls).toHaveLength(0) - }) - - test("does not match git commit-graph", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("bash", "git commit-graph write"), ctx) - expect(counters.commit.calls).toHaveLength(0) - }) - - test("does not match string containing 'git commit' in echo", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("bash", 'echo "run git commit to save"'), ctx) - expect(counters.commit.calls).toHaveLength(1) - }) - - test("matches git commit with --amend", () => { - const { ctx, counters } = makeCtx() - handleCommandExecuted(makeCommandExecuted("bash", "git commit --amend --no-edit"), ctx) - expect(counters.commit.calls).toHaveLength(1) - }) -}) diff --git a/tests/handlers/disabled-metrics.test.ts b/tests/handlers/disabled-metrics.test.ts deleted file mode 100644 index cece7d8..0000000 --- a/tests/handlers/disabled-metrics.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { handleSessionCreated, handleSessionIdle, handleSessionStatus } from "../../src/handlers/session.ts" -import { handleMessageUpdated, handleMessagePartUpdated } from "../../src/handlers/message.ts" -import { handleSessionDiff, handleCommandExecuted } from "../../src/handlers/activity.ts" -import { makeCtx } from "../helpers.ts" -import type { EventSessionCreated, EventSessionIdle, EventSessionStatus, EventMessageUpdated, EventMessagePartUpdated, EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" - -function makeSessionCreated(sessionID: string): EventSessionCreated { - return { - type: "session.created", - properties: { info: { id: sessionID, projectID: "proj_test", directory: "/tmp", time: { created: 1000 } } }, - } as unknown as EventSessionCreated -} - -function makeSessionIdle(sessionID: string): EventSessionIdle { - return { type: "session.idle", properties: { sessionID } } as EventSessionIdle -} - -function makeSessionStatus(sessionID: string): EventSessionStatus { - return { - type: "session.status", - properties: { sessionID, status: { type: "retry", attempt: 1, message: "rate limited", next: 5000 } }, - } as unknown as EventSessionStatus -} - -function makeAssistantMessage(sessionID = "ses_1"): EventMessageUpdated { - return { - type: "message.updated", - properties: { - info: { - id: "msg_1", role: "assistant", sessionID, - modelID: "claude-3-5-sonnet", providerID: "anthropic", - cost: 0.01, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 20, write: 5 } }, - time: { created: 1000, completed: 2000 }, - }, - }, - } as unknown as EventMessageUpdated -} - -function makeToolPart(status: "running" | "completed"): EventMessagePartUpdated { - return { - type: "message.part.updated", - properties: { - part: { - type: "tool", sessionID: "ses_1", callID: "call_1", tool: "bash", - state: status === "running" - ? { status: "running", time: { start: 1000 } } - : { status: "completed", time: { start: 1000, end: 1500 }, output: "ok" }, - }, - }, - } as unknown as EventMessagePartUpdated -} - -function makeSessionDiff(): EventSessionDiff { - return { - type: "session.diff", - properties: { sessionID: "ses_1", diff: [{ file: "a.ts", additions: 10, deletions: 3 }] }, - } as unknown as EventSessionDiff -} - -function makeCommandExecuted(cmd: string): EventCommandExecuted { - return { - type: "command.executed", - properties: { sessionID: "ses_1", name: "bash", arguments: cmd }, - } as unknown as EventCommandExecuted -} - -describe("OPENCODE_DISABLE_METRICS", () => { - describe("session.count disabled", () => { - test("does not increment session counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["session.count"]) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(counters.session.calls).toHaveLength(0) - }) - - test("still emits session.created log record", async () => { - const { ctx, logger } = makeCtx("proj_test", ["session.count"]) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(logger.records.at(0)!.body).toBe("session.created") - }) - }) - - describe("session.duration disabled", () => { - test("does not record duration histogram on idle", async () => { - const { ctx, histograms } = makeCtx("proj_test", ["session.duration"]) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(histograms.sessionDuration.calls).toHaveLength(0) - }) - }) - - describe("session.token.total disabled", () => { - test("does not record token gauge on idle", async () => { - const { ctx, gauges } = makeCtx("proj_test", ["session.token.total"]) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(gauges.sessionToken.calls).toHaveLength(0) - }) - }) - - describe("session.cost.total disabled", () => { - test("does not record cost gauge on idle", async () => { - const { ctx, gauges } = makeCtx("proj_test", ["session.cost.total"]) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(gauges.sessionCost.calls).toHaveLength(0) - }) - }) - - describe("retry.count disabled", () => { - test("does not increment retry counter", () => { - const { ctx, counters } = makeCtx("proj_test", ["retry.count"]) - handleSessionStatus(makeSessionStatus("ses_1"), ctx) - expect(counters.retry.calls).toHaveLength(0) - }) - }) - - describe("token.usage disabled", () => { - test("does not increment token counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["token.usage"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(counters.token.calls).toHaveLength(0) - }) - - test("still emits api_request log", async () => { - const { ctx, logger } = makeCtx("proj_test", ["token.usage"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(logger.records.at(0)!.body).toBe("api_request") - }) - }) - - describe("cost.usage disabled", () => { - test("does not increment cost counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["cost.usage"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(counters.cost.calls).toHaveLength(0) - }) - }) - - describe("cache.count disabled", () => { - test("does not increment cache counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["cache.count"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(counters.cache.calls).toHaveLength(0) - }) - }) - - describe("message.count disabled", () => { - test("does not increment message counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["message.count"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(counters.message.calls).toHaveLength(0) - }) - }) - - describe("model.usage disabled", () => { - test("does not increment model usage counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["model.usage"]) - await handleMessageUpdated(makeAssistantMessage(), ctx) - expect(counters.modelUsage.calls).toHaveLength(0) - }) - }) - - describe("tool.duration disabled", () => { - test("does not record tool duration histogram", async () => { - const { ctx, histograms } = makeCtx("proj_test", ["tool.duration"]) - await handleMessagePartUpdated(makeToolPart("running"), ctx) - await handleMessagePartUpdated(makeToolPart("completed"), ctx) - expect(histograms.tool.calls).toHaveLength(0) - }) - - test("still emits tool_result log", async () => { - const { ctx, logger } = makeCtx("proj_test", ["tool.duration"]) - await handleMessagePartUpdated(makeToolPart("running"), ctx) - await handleMessagePartUpdated(makeToolPart("completed"), ctx) - expect(logger.records.at(0)!.body).toBe("tool_result") - }) - }) - - describe("lines_of_code.count disabled", () => { - test("does not increment lines counter", () => { - const { ctx, counters } = makeCtx("proj_test", ["lines_of_code.count"]) - handleSessionDiff(makeSessionDiff(), ctx) - expect(counters.lines.calls).toHaveLength(0) - }) - }) - - describe("commit.count disabled", () => { - test("does not increment commit counter", () => { - const { ctx, counters } = makeCtx("proj_test", ["commit.count"]) - handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) - expect(counters.commit.calls).toHaveLength(0) - }) - - test("still emits commit log record", () => { - const { ctx, logger } = makeCtx("proj_test", ["commit.count"]) - handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) - expect(logger.records.at(0)!.body).toBe("commit") - }) - }) - - describe("subtask.count disabled", () => { - test("does not increment subtask counter", async () => { - const { ctx, counters } = makeCtx("proj_test", ["subtask.count"]) - const e = { - type: "message.part.updated", - properties: { - part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, - }, - } as unknown as EventMessagePartUpdated - await handleMessagePartUpdated(e, ctx) - expect(counters.subtask.calls).toHaveLength(0) - }) - - test("still emits subtask_invoked log record", async () => { - const { ctx, logger } = makeCtx("proj_test", ["subtask.count"]) - const e = { - type: "message.part.updated", - properties: { - part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, - }, - } as unknown as EventMessagePartUpdated - await handleMessagePartUpdated(e, ctx) - expect(logger.records.at(0)!.body).toBe("subtask_invoked") - }) - }) - - describe("multiple disabled at once", () => { - test("disabling all metrics stops all counter/histogram calls", async () => { - const all = [ - "session.count", "token.usage", "cost.usage", "lines_of_code.count", - "commit.count", "tool.duration", "cache.count", "session.duration", - "message.count", "session.token.total", "session.cost.total", - "model.usage", "retry.count", "subtask.count", - ] - const { ctx, counters, histograms, gauges } = makeCtx("proj_test", all) - const subtaskEvent = { - type: "message.part.updated", - properties: { - part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" }, - }, - } as unknown as EventMessagePartUpdated - - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - await handleMessageUpdated(makeAssistantMessage(), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - handleSessionStatus(makeSessionStatus("ses_1"), ctx) - handleSessionDiff(makeSessionDiff(), ctx) - handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) - await handleMessagePartUpdated(makeToolPart("running"), ctx) - await handleMessagePartUpdated(makeToolPart("completed"), ctx) - await handleMessagePartUpdated(subtaskEvent, ctx) - - expect(counters.session.calls).toHaveLength(0) - expect(counters.token.calls).toHaveLength(0) - expect(counters.cost.calls).toHaveLength(0) - expect(counters.cache.calls).toHaveLength(0) - expect(counters.message.calls).toHaveLength(0) - expect(counters.modelUsage.calls).toHaveLength(0) - expect(counters.retry.calls).toHaveLength(0) - expect(counters.lines.calls).toHaveLength(0) - expect(counters.commit.calls).toHaveLength(0) - expect(counters.subtask.calls).toHaveLength(0) - expect(histograms.tool.calls).toHaveLength(0) - expect(histograms.sessionDuration.calls).toHaveLength(0) - expect(gauges.sessionToken.calls).toHaveLength(0) - expect(gauges.sessionCost.calls).toHaveLength(0) - }) - }) -}) diff --git a/tests/handlers/disabled-signals.test.ts b/tests/handlers/disabled-signals.test.ts deleted file mode 100644 index 0bd847e..0000000 --- a/tests/handlers/disabled-signals.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { handleSessionCreated, handleSessionIdle } from "../../src/handlers/session.ts" -import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "../../src/handlers/message.ts" -import { handlePermissionReplied } from "../../src/handlers/permission.ts" -import { handleCommandExecuted } from "../../src/handlers/activity.ts" -import { makeCtx } from "../helpers.ts" -import type { - EventCommandExecuted, - EventMessagePartUpdated, - EventMessageUpdated, - EventPermissionReplied, - EventSessionCreated, - EventSessionIdle, -} from "@opencode-ai/sdk" - -function makeSessionCreated(sessionID: string): EventSessionCreated { - return { - type: "session.created", - properties: { info: { id: sessionID, projectID: "proj_test", directory: "/tmp", time: { created: 1000 } } }, - } as unknown as EventSessionCreated -} - -function makeSessionIdle(sessionID: string): EventSessionIdle { - return { type: "session.idle", properties: { sessionID } } as EventSessionIdle -} - -function makeAssistantMessageUpdated(overrides: { error?: { name: string } } = {}): EventMessageUpdated { - return { - type: "message.updated", - properties: { - info: { - id: "msg_1", - role: "assistant", - sessionID: "ses_1", - modelID: "claude-3-5-sonnet", - providerID: "anthropic", - cost: 0.01, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000, completed: 2000 }, - error: overrides.error, - }, - }, - } as unknown as EventMessageUpdated -} - -function makeToolPartUpdated(status: "running" | "completed"): EventMessagePartUpdated { - return { - type: "message.part.updated", - properties: { - part: { - type: "tool", - sessionID: "ses_1", - callID: "call_1", - tool: "bash", - state: status === "running" - ? { status: "running", time: { start: 1000 } } - : { status: "completed", time: { start: 1000, end: 1500 }, output: "ok" }, - }, - }, - } as unknown as EventMessagePartUpdated -} - -function makePermissionReplied(): EventPermissionReplied { - return { - type: "permission.replied", - properties: { permissionID: "perm_1", sessionID: "ses_1", response: "allow" }, - } as unknown as EventPermissionReplied -} - -function makeCommandExecuted(cmd: string): EventCommandExecuted { - return { - type: "command.executed", - properties: { sessionID: "ses_1", name: "bash", arguments: cmd }, - } as unknown as EventCommandExecuted -} - -describe("disabled logs", () => { - test("suppresses OTLP logs while leaving metrics enabled", async () => { - const { ctx, logger, counters } = makeCtx("proj_test", [], [], false) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(logger.records).toHaveLength(0) - expect(counters.session.calls).toHaveLength(1) - }) - - test("suppresses assistant request/error logs", async () => { - const { ctx, logger } = makeCtx("proj_test", [], [], false) - await handleMessageUpdated(makeAssistantMessageUpdated(), ctx) - await handleMessageUpdated(makeAssistantMessageUpdated({ error: { name: "APIError" } }), ctx) - expect(logger.records).toHaveLength(0) - }) - - test("suppresses tool, permission, commit, and idle logs", async () => { - const { ctx, logger } = makeCtx("proj_test", [], [], false) - ctx.pendingPermissions.set("perm_1", { type: "bash", title: "run bash", sessionID: "ses_1" }) - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) - handlePermissionReplied(makePermissionReplied(), ctx) - handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(logger.records).toHaveLength(0) - }) -}) - -describe("disabled traces", () => { - test("disabling all three trace types suppresses every span", async () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["session", "llm", "tool"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) - await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - expect(tracer.spans).toHaveLength(0) - }) -}) diff --git a/tests/handlers/disabled.test.ts b/tests/handlers/disabled.test.ts new file mode 100644 index 0000000..754e0dc --- /dev/null +++ b/tests/handlers/disabled.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from "bun:test" +import { handleExecutionStarted, handleSessionCreated, handleSessionStatus, handleUsageUpdated, finalizeSession } from "../../src/handlers/session.ts" +import { handleStepEnded, handleStepStarted } from "../../src/handlers/step.ts" +import { handleToolInputStarted } from "../../src/handlers/tool.ts" +import { makeCtx, evt, tokens } from "../helpers.ts" + +describe("disabled metrics", () => { + test("suppresses individual counters", () => { + const { ctx, counters } = makeCtx("proj_test", ["session.count", "token.usage", "cost.usage", "cache.count", "message.count", "model.usage", "subtask.count"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1", parentID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleStepStarted(evt("session.step.started", { sessionID: "ses_1", assistantMessageID: "m", agent: "build", model: { id: "x", providerID: "anthropic" } }), ctx) + handleStepEnded(evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "m", finish: "stop", cost: 1, tokens: tokens(1, 1, 1, 1, 1) }), ctx) + expect(counters.session.calls).toHaveLength(0) + expect(counters.token.calls).toHaveLength(0) + expect(counters.cost.calls).toHaveLength(0) + expect(counters.cache.calls).toHaveLength(0) + expect(counters.message.calls).toHaveLength(0) + expect(counters.modelUsage.calls).toHaveLength(0) + expect(counters.subtask.calls).toHaveLength(0) + }) + + test("suppresses retry and session total histograms", () => { + const { ctx, counters, histograms } = makeCtx("proj_test", ["retry.count", "session.duration", "session.token.total", "session.cost.total"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleSessionStatus(evt("session.status", { sessionID: "ses_1", status: { type: "retry", attempt: 1, message: "m", next: 1 } }), ctx) + handleUsageUpdated(evt("session.usage.updated", { sessionID: "ses_1", cost: 1, tokens: tokens() }), ctx) + finalizeSession("ses_1", ctx) + expect(counters.retry.calls).toHaveLength(0) + expect(histograms.sessionDuration.calls).toHaveLength(0) + expect(histograms.sessionToken.calls).toHaveLength(0) + expect(histograms.sessionCost.calls).toHaveLength(0) + }) +}) + +describe("disabled traces", () => { + test("session spans can be disabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], ["session"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans).toHaveLength(0) + }) + + test("llm spans can be disabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], ["llm"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleStepStarted(evt("session.step.started", { sessionID: "ses_1", assistantMessageID: "m", agent: "build", model: { id: "x", providerID: "anthropic" } }), ctx) + expect(tracer.spans.map((s) => s.name)).toEqual(["opencode.session"]) + }) + + test("tool spans can be disabled", () => { + const { ctx, tracer } = makeCtx("proj_test", [], ["tool"]) + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "m", id: "c", name: "read" }), ctx) + expect(tracer.spans.map((s) => s.name)).toEqual(["opencode.session"]) + }) +}) diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts deleted file mode 100644 index 0fed30a..0000000 --- a/tests/handlers/message.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { handleMessageUpdated, handleMessagePartUpdated } from "../../src/handlers/message.ts" -import { makeCtx } from "../helpers.ts" -import type { EventMessageUpdated, EventMessagePartUpdated } from "@opencode-ai/sdk" - -function makeSubtaskPartUpdated(overrides: { - sessionID?: string - agent?: string - description?: string - prompt?: string -} = {}): EventMessagePartUpdated { - return { - type: "message.part.updated", - properties: { - part: { - type: "subtask", - sessionID: overrides.sessionID ?? "ses_1", - messageID: "msg_1", - agent: overrides.agent ?? "build", - description: overrides.description ?? "Build the project", - prompt: overrides.prompt ?? "Run the build and fix errors", - }, - }, - } as unknown as EventMessagePartUpdated -} - -function makeAssistantMessageUpdated(overrides: { - sessionID?: string - modelID?: string - providerID?: string - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time?: { created: number; completed: number } - error?: { name: string } -}): EventMessageUpdated { - return { - type: "message.updated", - properties: { - info: { - id: "msg_1", - role: "assistant", - sessionID: overrides.sessionID ?? "ses_1", - modelID: overrides.modelID ?? "claude-3-5-sonnet", - providerID: overrides.providerID ?? "anthropic", - cost: overrides.cost ?? 0.01, - tokens: overrides.tokens ?? { - input: 100, - output: 50, - reasoning: 0, - cache: { read: 10, write: 5 }, - }, - time: overrides.time ?? { created: 1000, completed: 2000 }, - error: overrides.error, - }, - }, - } as unknown as EventMessageUpdated -} - -function makeUserMessageUpdated(): EventMessageUpdated { - return { - type: "message.updated", - properties: { info: { id: "msg_1", role: "user" } }, - } as unknown as EventMessageUpdated -} - -function makeIncompleteAssistantMessage(): EventMessageUpdated { - return { - type: "message.updated", - properties: { - info: { - id: "msg_1", - role: "assistant", - sessionID: "ses_1", - modelID: "claude", - providerID: "anthropic", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 1000, completed: undefined }, - }, - }, - } as unknown as EventMessageUpdated -} - -function makeToolPartUpdated( - status: "running" | "completed" | "error", - overrides: { sessionID?: string; callID?: string; tool?: string; startMs?: number; endMs?: number } = {}, -): EventMessagePartUpdated { - const sessionID = overrides.sessionID ?? "ses_1" - const callID = overrides.callID ?? "call_1" - const start = overrides.startMs ?? 1000 - const end = overrides.endMs ?? 2000 - - const state = - status === "running" - ? { status: "running", time: { start } } - : status === "completed" - ? { status: "completed", time: { start, end }, output: "result output" } - : { status: "error", time: { start, end }, error: "tool failed" } - - return { - type: "message.part.updated", - properties: { - part: { - type: "tool", - sessionID, - callID, - tool: overrides.tool ?? "bash", - state, - }, - }, - } as unknown as EventMessagePartUpdated -} - -describe("handleMessageUpdated", () => { - test("ignores user messages", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated(makeUserMessageUpdated(), ctx) - expect(counters.token.calls).toHaveLength(0) - }) - - test("ignores incomplete assistant messages", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated(makeIncompleteAssistantMessage(), ctx) - expect(counters.token.calls).toHaveLength(0) - }) - - test("increments all token counters", async () => { - const { ctx, counters } = makeCtx("proj_test", [], [], true, { team: "platform" }) - await handleMessageUpdated( - makeAssistantMessageUpdated({ - tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } }, - }), - ctx, - ) - const types = counters.token.calls.map((c) => c.attrs["type"]) - expect(types).toContain("input") - expect(types).toContain("output") - expect(types).toContain("reasoning") - expect(types).toContain("cacheRead") - expect(types).toContain("cacheCreation") - const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! - expect(inputCall.value).toBe(100) - expect(inputCall.attrs["team"]).toBe("platform") - }) - - test("increments cost counter", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated(makeAssistantMessageUpdated({ cost: 0.05 }), ctx) - expect(counters.cost.calls).toHaveLength(1) - expect(counters.cost.calls.at(0)!.value).toBe(0.05) - }) - - test("increments cache counter once per message with cache activity", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated( - makeAssistantMessageUpdated({ tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 200, write: 50 } } }), - ctx, - ) - const types = counters.cache.calls.map(c => c.attrs["type"]) - expect(types).toContain("cacheRead") - expect(types).toContain("cacheCreation") - expect(counters.cache.calls.find(c => c.attrs["type"] === "cacheRead")!.value).toBe(1) - expect(counters.cache.calls.find(c => c.attrs["type"] === "cacheCreation")!.value).toBe(1) - }) - - test("does not increment cache counter when cache tokens are zero", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated( - makeAssistantMessageUpdated({ tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } } }), - ctx, - ) - expect(counters.cache.calls).toHaveLength(0) - }) - - test("increments message counter", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1", modelID: "claude-3-5-sonnet" }), ctx) - expect(counters.message.calls).toHaveLength(1) - expect(counters.message.calls.at(0)!.value).toBe(1) - expect(counters.message.calls.at(0)!.attrs["session.id"]).toBe("ses_1") - }) - - test("increments model usage counter with session.id, model and provider", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated( - makeAssistantMessageUpdated({ sessionID: "ses_1", modelID: "claude-3-5-sonnet", providerID: "anthropic" }), - ctx, - ) - expect(counters.modelUsage.calls).toHaveLength(1) - const call = counters.modelUsage.calls.at(0)! - expect(call.attrs["session.id"]).toBe("ses_1") - expect(call.attrs["model"]).toBe("claude-3-5-sonnet") - expect(call.attrs["provider"]).toBe("anthropic") - }) - - test("accumulates session totals including cache tokens", async () => { - const { ctx } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - await handleMessageUpdated( - makeAssistantMessageUpdated({ - sessionID: "ses_1", - cost: 0.02, - tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } }, - }), - ctx, - ) - const totals = ctx.sessionTotals.get("ses_1")! - expect(totals.tokens).toBe(185) - expect(totals.cost).toBe(0.02) - expect(totals.messages).toBe(1) - }) - - test("emits api_request log record on success", async () => { - const { ctx, logger } = makeCtx("proj_test", [], [], true, { team: "platform" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ providerID: "amazon-bedrock" }), ctx) - expect(logger.records).toHaveLength(1) - expect(logger.records.at(0)!.body).toBe("api_request") - expect(logger.records.at(0)!.attributes?.["provider"]).toBe("amazon-bedrock") - expect(logger.records.at(0)!.attributes?.["gen_ai.provider.name"]).toBe("aws.bedrock") - expect(logger.records.at(0)!.attributes?.["team"]).toBe("platform") - }) - - test("emits api_error log record on error", async () => { - const { ctx, logger, pluginLog } = makeCtx() - await handleMessageUpdated( - makeAssistantMessageUpdated({ providerID: "google-vertex", error: { name: "APIError" } }), - ctx, - ) - expect(logger.records.at(0)!.body).toBe("api_error") - expect(logger.records.at(0)!.attributes?.["provider"]).toBe("google-vertex") - expect(logger.records.at(0)!.attributes?.["gen_ai.provider.name"]).toBe("gcp.vertex_ai") - expect(logger.records.at(0)!.attributes?.["error"]).toBe("APIError") - expect(pluginLog.calls.find(c => c.level === "error")?.level).toBe("error") - }) - - test("uses assistant.time.created as log timestamp", async () => { - const { ctx, logger } = makeCtx() - await handleMessageUpdated( - makeAssistantMessageUpdated({ time: { created: 5000, completed: 6000 } }), - ctx, - ) - expect(logger.records.at(0)!.timestamp).toBe(5000) - }) -}) - -describe("handleMessagePartUpdated", () => { - test("ignores non-tool parts", async () => { - const { ctx, histograms } = makeCtx() - const e = { - type: "message.part.updated", - properties: { part: { type: "text", text: "hello", sessionID: "ses_1" } }, - } as unknown as EventMessagePartUpdated - await handleMessagePartUpdated(e, ctx) - expect(histograms.tool.calls).toHaveLength(0) - }) - - test("stores running tool in pendingToolSpans", async () => { - const { ctx } = makeCtx() - await handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1234 }), ctx) - expect(ctx.pendingToolSpans.has("ses_1:call_1")).toBe(true) - expect(ctx.pendingToolSpans.get("ses_1:call_1")!.startMs).toBe(1234) - }) - - test("records histogram on tool completion", async () => { - const { ctx, histograms } = makeCtx() - await handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1000 }), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("completed", { startMs: 1000, endMs: 1500 }), ctx) - expect(histograms.tool.calls).toHaveLength(1) - expect(histograms.tool.calls.at(0)!.value).toBe(500) - expect(histograms.tool.calls.at(0)!.attrs["tool_name"]).toBe("bash") - expect(histograms.tool.calls.at(0)!.attrs["success"]).toBe(true) - }) - - test("uses stored startMs from running span for duration", async () => { - const { ctx, histograms } = makeCtx() - await handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 900 }), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("completed", { startMs: 1000, endMs: 1900 }), ctx) - expect(histograms.tool.calls.at(0)!.value).toBe(1000) - }) - - test("emits tool_result log on success with exact byte length", async () => { - const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) - const record = logger.records.at(0)! - expect(record.body).toBe("tool_result") - expect(record.attributes?.["success"]).toBe(true) - expect(record.attributes?.["tool_result_size_bytes"]).toBe(Buffer.byteLength("result output", "utf8")) - expect(record.attributes?.["agent.name"]).toBe("build") - expect(record.attributes?.["agent.type"]).toBe("primary") - }) - - test("emits error-severity log on tool error", async () => { - const { ctx, logger, pluginLog } = makeCtx() - await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - await handleMessagePartUpdated(makeToolPartUpdated("error"), ctx) - const record = logger.records.at(0)! - expect(record.body).toBe("tool_result") - expect(record.attributes?.["success"]).toBe(false) - expect(record.attributes?.["error"]).toBe("tool failed") - expect(pluginLog.calls.find(c => c.level === "error")?.level).toBe("error") - }) - - test("removes entry from pendingToolSpans after completion", async () => { - const { ctx } = makeCtx() - await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - expect(ctx.pendingToolSpans.size).toBe(1) - await handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) - expect(ctx.pendingToolSpans.size).toBe(0) - }) - - test("skips recording when time.end is undefined", async () => { - const { ctx, histograms } = makeCtx() - const e = { - type: "message.part.updated", - properties: { - part: { - type: "tool", - sessionID: "ses_1", - callID: "call_1", - tool: "bash", - state: { status: "completed", time: { start: 1000, end: undefined }, output: "ok" }, - }, - }, - } as unknown as EventMessagePartUpdated - await handleMessagePartUpdated(e, ctx) - expect(histograms.tool.calls).toHaveLength(0) - }) -}) - -describe("handleMessageUpdated — agent attribute", () => { - test("includes agent attr on token counters from session totals", async () => { - const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan", agentType: "primary" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) - const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! - expect(inputCall.attrs["agent"]).toBe("plan") - }) - - test("includes agent attr on cost counter", async () => { - const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) - expect(counters.cost.calls.at(0)!.attrs["agent"]).toBe("build") - }) - - test("includes agent attr on message counter", async () => { - const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "general", agentType: "primary" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) - expect(counters.message.calls.at(0)!.attrs["agent"]).toBe("general") - }) - - test("includes agent attr on model usage counter", async () => { - const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "primary" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) - expect(counters.modelUsage.calls.at(0)!.attrs["agent"]).toBe("review") - }) - - test("includes agent attr on cache counters", async () => { - const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "tdd", agentType: "primary" }) - await handleMessageUpdated( - makeAssistantMessageUpdated({ sessionID: "ses_1", tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 10, write: 5 } } }), - ctx, - ) - expect(counters.cache.calls.at(0)!.attrs["agent"]).toBe("tdd") - }) - - test("defaults agent to 'unknown' when session totals are absent", async () => { - const { ctx, counters } = makeCtx() - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_no_totals" }), ctx) - const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! - expect(inputCall.attrs["agent"]).toBe("unknown") - }) - - test("includes agent on api_request log record", async () => { - const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan", agentType: "primary" }) - await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) - expect(logger.records.at(0)!.attributes?.["agent"]).toBe("plan") - expect(logger.records.at(0)!.attributes?.["agent.name"]).toBe("plan") - expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") - }) - - test("includes agent on api_error log record", async () => { - const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - await handleMessageUpdated( - makeAssistantMessageUpdated({ sessionID: "ses_1", error: { name: "APIError" } }), - ctx, - ) - expect(logger.records.at(0)!.attributes?.["agent"]).toBe("build") - expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") - }) -}) - -describe("handleMessagePartUpdated — subtask parts", () => { - test("increments subtask counter with agent and session.id attrs", async () => { - const { ctx, counters } = makeCtx() - await handleMessagePartUpdated(makeSubtaskPartUpdated({ sessionID: "ses_1", agent: "build" }), ctx) - expect(counters.subtask.calls).toHaveLength(1) - const call = counters.subtask.calls.at(0)! - expect(call.value).toBe(1) - expect(call.attrs["agent"]).toBe("build") - expect(call.attrs["session.id"]).toBe("ses_1") - }) - - test("emits subtask_invoked log record", async () => { - const { ctx, logger } = makeCtx() - await handleMessagePartUpdated( - makeSubtaskPartUpdated({ agent: "plan", description: "Plan the feature", prompt: "Create a plan" }), - ctx, - ) - expect(logger.records).toHaveLength(1) - const record = logger.records.at(0)! - expect(record.body).toBe("subtask_invoked") - expect(record.attributes?.["agent"]).toBe("plan") - expect(record.attributes?.["agent.name"]).toBe("plan") - expect(record.attributes?.["agent.type"]).toBe("subagent") - expect(record.attributes?.["description"]).toBe("Plan the feature") - expect(record.attributes?.["prompt_length"]).toBe("Create a plan".length) - }) - - test("includes project.id in subtask counter attrs", async () => { - const { ctx, counters } = makeCtx("proj_xyz") - await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) - expect(counters.subtask.calls.at(0)!.attrs["project.id"]).toBe("proj_xyz") - }) - - test("does not record subtask counter when subtask.count is disabled", async () => { - const { ctx, counters } = makeCtx("proj_test", ["subtask.count"]) - await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) - expect(counters.subtask.calls).toHaveLength(0) - }) - - test("still emits subtask_invoked log when subtask.count is disabled", async () => { - const { ctx, logger } = makeCtx("proj_test", ["subtask.count"]) - await handleMessagePartUpdated(makeSubtaskPartUpdated(), ctx) - expect(logger.records.at(0)!.body).toBe("subtask_invoked") - }) - - test("does not affect tool handling for non-subtask non-tool parts", async () => { - const { ctx, counters, histograms } = makeCtx() - const e = { - type: "message.part.updated", - properties: { part: { type: "text", text: "hello", sessionID: "ses_1" } }, - } as unknown as EventMessagePartUpdated - await handleMessagePartUpdated(e, ctx) - expect(counters.subtask.calls).toHaveLength(0) - expect(histograms.tool.calls).toHaveLength(0) - }) -}) diff --git a/tests/handlers/permission.test.ts b/tests/handlers/permission.test.ts index 89b7885..5ddf496 100644 --- a/tests/handlers/permission.test.ts +++ b/tests/handlers/permission.test.ts @@ -1,86 +1,31 @@ import { describe, test, expect } from "bun:test" -import { handlePermissionUpdated, handlePermissionReplied } from "../../src/handlers/permission.ts" -import { makeCtx } from "../helpers.ts" -import type { EventPermissionUpdated, EventPermissionReplied } from "@opencode-ai/sdk" +import { handlePermissionAsked, handlePermissionReplied } from "../../src/handlers/permission.ts" +import { makeCtx, evt } from "../helpers.ts" -function makePermissionUpdated(id: string, sessionID = "ses_1"): EventPermissionUpdated { - return { - type: "permission.updated", - properties: { - id, - type: "tool", - title: "Read file", - sessionID, - messageID: "msg_1", - metadata: {}, - time: { created: Date.now() }, - }, - } as unknown as EventPermissionUpdated -} - -function makePermissionReplied( - permissionID: string, - response: string, - sessionID = "ses_1", -): EventPermissionReplied { - return { - type: "permission.replied", - properties: { permissionID, sessionID, response }, - } as unknown as EventPermissionReplied -} - -describe("handlePermissionUpdated", () => { - test("stores permission in pendingPermissions", () => { +describe("handlePermissionAsked", () => { + test("stores the pending permission", () => { const { ctx } = makeCtx() - handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) - expect(ctx.pendingPermissions.has("perm_1")).toBe(true) - expect(ctx.pendingPermissions.get("perm_1")!.title).toBe("Read file") - expect(ctx.pendingPermissions.get("perm_1")!.sessionID).toBe("ses_1") + handlePermissionAsked(evt("permission.asked", { id: "perm_1", sessionID: "ses_1", action: "bash", resources: ["git push"] }), ctx) + expect(ctx.tracing.pendingPermissions.get("perm_1")!.action).toBe("bash") }) }) describe("handlePermissionReplied", () => { - test("emits tool_decision log on allow", () => { + test("emits an accept tool_decision", () => { const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "subagent" }) - handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) - handlePermissionReplied(makePermissionReplied("perm_1", "allow"), ctx) - const record = logger.records.at(0)! + handlePermissionAsked(evt("permission.asked", { id: "perm_1", sessionID: "ses_1", action: "bash", resources: ["git push"] }), ctx) + handlePermissionReplied(evt("permission.replied", { sessionID: "ses_1", requestID: "perm_1", reply: "always" }), ctx) + const record = logger.records.at(-1)! expect(record.body).toBe("tool_decision") expect(record.attributes?.["decision"]).toBe("accept") - expect(record.attributes?.["source"]).toBe("allow") - expect(record.attributes?.["tool_name"]).toBe("Read file") - expect(record.attributes?.["tool_type"]).toBe("tool") - expect(record.attributes?.["agent.name"]).toBe("review") - expect(record.attributes?.["agent.type"]).toBe("subagent") - }) - - test("emits tool_decision log on allowAlways", () => { - const { ctx, logger } = makeCtx() - handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) - handlePermissionReplied(makePermissionReplied("perm_1", "allowAlways"), ctx) - expect(logger.records.at(0)!.attributes?.["decision"]).toBe("accept") - }) - - test("emits reject decision on deny", () => { - const { ctx, logger } = makeCtx() - handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) - handlePermissionReplied(makePermissionReplied("perm_1", "deny"), ctx) - expect(logger.records.at(0)!.attributes?.["decision"]).toBe("reject") - }) - - test("removes permission from pendingPermissions after reply", () => { - const { ctx } = makeCtx() - handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) - expect(ctx.pendingPermissions.size).toBe(1) - handlePermissionReplied(makePermissionReplied("perm_1", "allow"), ctx) - expect(ctx.pendingPermissions.size).toBe(0) + expect(record.attributes?.["tool_name"]).toBe("bash") + expect(record.attributes?.["source"]).toBe("always") }) - test("uses 'unknown' for tool_name when no pending entry", () => { + test("emits a reject tool_decision", () => { const { ctx, logger } = makeCtx() - handlePermissionReplied(makePermissionReplied("perm_missing", "allow"), ctx) - expect(logger.records.at(0)!.attributes?.["tool_name"]).toBe("unknown") - expect(logger.records.at(0)!.attributes?.["tool_type"]).toBe("unknown") + handlePermissionAsked(evt("permission.asked", { id: "perm_2", sessionID: "ses_1", action: "edit", resources: ["a.ts"] }), ctx) + handlePermissionReplied(evt("permission.replied", { sessionID: "ses_1", requestID: "perm_2", reply: "reject" }), ctx) + expect(logger.records.at(-1)!.attributes?.["decision"]).toBe("reject") }) }) diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index bbada8a..158e089 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -1,263 +1,143 @@ import { describe, test, expect } from "bun:test" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSessionStatus } from "../../src/handlers/session.ts" -import { makeCtx, makeTracer } from "../helpers.ts" -import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" -import type { Span } from "@opentelemetry/api" - -function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated { - return { - type: "session.created", - properties: { - info: { - id: sessionID, - projectID: "proj_test", - directory: "/tmp", - parentID, - time: { created: createdAt }, - }, - }, - } as unknown as EventSessionCreated -} - -function makeSessionIdle(sessionID: string): EventSessionIdle { - return { type: "session.idle", properties: { sessionID } } as EventSessionIdle -} - -function makeSessionError(sessionID: string, error?: { name: string }): EventSessionError { - return { - type: "session.error", - properties: { sessionID, error }, - } as unknown as EventSessionError -} - -function makeSessionStatus(sessionID: string, status: { type: "retry"; attempt: number; message: string; next: number } | { type: "busy" } | { type: "idle" }): EventSessionStatus { - return { type: "session.status", properties: { sessionID, status } } as unknown as EventSessionStatus -} +import { SpanStatusCode } from "@opentelemetry/api" +import { + finalizeSession, + handleExecutionEnded, + handleExecutionStarted, + handleSessionCreated, + handleSessionIdle, + handleSessionStatus, + handleUsageUpdated, +} from "../../src/handlers/session.ts" +import { makeCtx, evt, tokens } from "../helpers.ts" describe("handleSessionCreated", () => { - test("increments session counter", async () => { + test("increments the session counter and stores totals", () => { const { ctx, counters } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) + handleSessionCreated(evt("session.created", { sessionID: "ses_1", created: 1000, projectID: "proj_test", location: {}, slug: "s", version: "1" }), ctx) expect(counters.session.calls).toHaveLength(1) - const call = counters.session.calls.at(0)! - expect(call.value).toBe(1) - expect(call.attrs["session.id"]).toBe("ses_1") - }) - - test("emits session.created log record with correct timestamp", async () => { - const { ctx, logger } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1", 9999), ctx) - expect(logger.records).toHaveLength(1) - const record = logger.records.at(0)! - expect(record.body).toBe("session.created") - expect(record.timestamp).toBe(9999) - expect(record.attributes?.["session.id"]).toBe("ses_1") - }) - - test("calls plugin log", async () => { - const { ctx, pluginLog } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(pluginLog.calls).toHaveLength(1) - const call = pluginLog.calls.at(0)! - expect(call.level).toBe("info") - expect(call.extra?.["sessionID"]).toBe("ses_1") + expect(counters.session.calls[0]!.attrs["session.id"]).toBe("ses_1") + expect(counters.session.calls[0]!.attrs["is_subagent"]).toBe(false) + const totals = ctx.tracing.sessionTotals.get("ses_1")! + expect(totals.startMs).toBe(1000) + expect(totals.agentType).toBe("primary") }) - test("includes project.id in counter attrs", async () => { - const { ctx, counters } = makeCtx("proj_abc") - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(counters.session.calls.at(0)!.attrs["project.id"]).toBe("proj_abc") - }) - - test("stores session totals with startMs", async () => { - const { ctx } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1", 5000), ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(true) - const totals = ctx.sessionTotals.get("ses_1")! - expect(totals.startMs).toBe(5000) - expect(totals.tokens).toBe(0) - expect(totals.cost).toBe(0) - expect(totals.messages).toBe(0) + test("marks subagent sessions and increments the subtask counter", () => { + const { ctx, counters, logger } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "sub_1", parentID: "ses_1", agent: "explore" }), ctx) + expect(counters.session.calls[0]!.attrs["is_subagent"]).toBe(true) + expect(counters.subtask.calls).toHaveLength(1) + expect(ctx.tracing.sessionTotals.get("sub_1")!.parentID).toBe("ses_1") + expect(logger.records[0]!.attributes?.["agent.name"]).toBe("explore") }) }) -describe("handleSessionIdle", () => { - test("emits session.idle log record", () => { - const { ctx, logger } = makeCtx() - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(logger.records).toHaveLength(1) - const record = logger.records.at(0)! - expect(record.body).toBe("session.idle") - expect(record.attributes?.["session.id"]).toBe("ses_1") - }) - - test("sweeps pendingPermissions for the session", () => { - const { ctx } = makeCtx() - ctx.pendingPermissions.set("perm_1", { type: "tool", title: "Read", sessionID: "ses_1" }) - ctx.pendingPermissions.set("perm_2", { type: "tool", title: "Write", sessionID: "ses_other" }) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.pendingPermissions.has("perm_1")).toBe(false) - expect(ctx.pendingPermissions.has("perm_2")).toBe(true) - }) - - test("sweeps pendingToolSpans for the session", () => { - const { ctx } = makeCtx() - const t = makeTracer() - const span1 = t.startSpan("tool") as unknown as Span - const span2 = t.startSpan("tool") as unknown as Span - ctx.pendingToolSpans.set("ses_1:call_1", { tool: "bash", sessionID: "ses_1", startMs: 0, span: span1 }) - ctx.pendingToolSpans.set("ses_other:call_2", { tool: "bash", sessionID: "ses_other", startMs: 0, span: span2 }) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.pendingToolSpans.has("ses_1:call_1")).toBe(false) - expect(ctx.pendingToolSpans.has("ses_other:call_2")).toBe(true) - }) - - test("records session duration histogram when totals exist", async () => { - const { ctx, histograms } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1", Date.now() - 1000), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(histograms.sessionDuration.calls).toHaveLength(1) - expect(histograms.sessionDuration.calls.at(0)!.value).toBeGreaterThan(0) - expect(histograms.sessionDuration.calls.at(0)!.attrs["session.id"]).toBe("ses_1") - }) - - test("records session token and cost histograms when totals exist", async () => { - const { ctx, gauges } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 500, tokens: 150, cost: 0.03, messages: 2, agent: "build", agentType: "primary" }) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(gauges.sessionToken.calls).toHaveLength(1) - expect(gauges.sessionToken.calls.at(0)!.value).toBe(150) - expect(gauges.sessionCost.calls).toHaveLength(1) - expect(gauges.sessionCost.calls.at(0)!.value).toBe(0.03) - }) - - test("emits total_tokens and total_messages in log record attributes", async () => { - const { ctx, logger } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 200, cost: 0.05, messages: 3, agent: "general", agentType: "primary" }) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - const record = logger.records.find(r => r.body === "session.idle")! - expect(record.attributes?.["total_tokens"]).toBe(200) - expect(record.attributes?.["total_cost_usd"]).toBe(0.05) - expect(record.attributes?.["total_messages"]).toBe(3) - expect(record.attributes?.["agent.name"]).toBe("general") - expect(record.attributes?.["agent.type"]).toBe("primary") - }) - - test("does not record histograms when no prior session.created", () => { - const { ctx, histograms, gauges } = makeCtx() - handleSessionIdle(makeSessionIdle("ses_unknown"), ctx) - expect(histograms.sessionDuration.calls).toHaveLength(0) - expect(gauges.sessionToken.calls).toHaveLength(0) - expect(gauges.sessionCost.calls).toHaveLength(0) - }) - - test("removes sessionTotals entry on idle", async () => { - const { ctx } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(true) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(false) +describe("handleExecutionStarted", () => { + test("starts a run span and attaches the pending prompt", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + ctx.tracing.pendingPrompts.set("ses_1", { text: "hello", startMs: 1 }) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }, 2000), ctx) + const span = tracer.spans[0]! + expect(span.name).toBe("opencode.session") + expect(span.startTime).toBe(2000) + expect(span.attributes["input.value"]).toBe("hello") + expect(ctx.tracing.runSpans.has("ses_1")).toBe(true) + expect(ctx.tracing.pendingPrompts.get("ses_1")!.text).toBe("hello") + }) + + test("nests subagent runs under the parent run span", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }, 100), ctx) + handleSessionCreated(evt("session.created", { sessionID: "sub_1", parentID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "sub_1" }, 200), ctx) + const parentRun = tracer.spans.find((s) => s.attributes["session.id"] === "ses_1")! + const subRun = tracer.spans.find((s) => s.attributes["session.id"] === "sub_1")! + expect(subRun.parentSpan).toBe(parentRun) + }) + + test("lazily initializes and counts a session with no session.created event", () => { + const { ctx, counters } = makeCtx() + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_new" }, 100), ctx) + expect(ctx.tracing.sessionTotals.has("ses_new")).toBe(true) + expect(counters.session.calls).toHaveLength(1) }) }) -describe("handleSessionError", () => { - test("emits session.error log record", () => { - const { ctx, logger } = makeCtx() - handleSessionError(makeSessionError("ses_1", { name: "NetworkError" }), ctx) - expect(logger.records).toHaveLength(1) - const record = logger.records.at(0)! - expect(record.body).toBe("session.error") - expect(record.attributes?.["error"]).toBe("NetworkError") - }) - - test("defaults sessionID to 'unknown' when undefined", () => { - const { ctx, logger } = makeCtx() - handleSessionError({ type: "session.error", properties: {} } as unknown as EventSessionError, ctx) - expect(logger.records.at(0)!.attributes?.["session.id"]).toBe("unknown") - }) - - test("sweeps pending maps on error", () => { - const { ctx } = makeCtx() - const t = makeTracer() - const span = t.startSpan("tool") as unknown as Span - ctx.pendingPermissions.set("perm_1", { type: "tool", title: "Read", sessionID: "ses_1" }) - ctx.pendingToolSpans.set("ses_1:call_1", { tool: "bash", sessionID: "ses_1", startMs: 0, span }) - handleSessionError(makeSessionError("ses_1"), ctx) - expect(ctx.pendingPermissions.size).toBe(0) - expect(ctx.pendingToolSpans.size).toBe(0) - }) - - test("removes sessionTotals entry on error when sessionID is known", async () => { - const { ctx } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(true) - handleSessionError(makeSessionError("ses_1"), ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(false) - }) - - test("does not delete sessionTotals when sessionID is undefined", async () => { - const { ctx } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleSessionError({ type: "session.error", properties: {} } as unknown as EventSessionError, ctx) - expect(ctx.sessionTotals.has("ses_1")).toBe(true) +describe("handleExecutionEnded", () => { + test("ends the run span OK and records totals", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", agent: "build" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "ses_1" }, 5000), ctx, { type: "succeeded" }) + const span = tracer.spans[0]! + expect(span.ended).toBe(true) + expect(span.endTime).toBe(5000) + expect(span.status.code).toBe(SpanStatusCode.OK) + expect(span.attributes["agent.name"]).toBe("build") + }) + + test("failed execution ends the span with an error and emits session.error", () => { + const { ctx, tracer, logger } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleExecutionEnded( + evt("session.execution.failed", { sessionID: "ses_1", error: { type: "ProviderError", message: "boom" } }), + ctx, + { type: "failed", error: { type: "ProviderError", message: "boom" } }, + ) + expect(tracer.spans[0]!.status.code).toBe(SpanStatusCode.ERROR) + expect(logger.records.at(-1)!.body).toBe("session.error") + expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ProviderError: boom") }) }) -describe("handleSessionCreated — is_subagent", () => { - test("tags session counter with is_subagent=false when no parentID", async () => { - const { ctx, counters } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(counters.session.calls.at(0)!.attrs["is_subagent"]).toBe(false) - }) - - test("tags session counter with is_subagent=true when parentID is present", async () => { +describe("handleSessionStatus", () => { + test("counts retries", () => { const { ctx, counters } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) - expect(counters.session.calls.at(0)!.attrs["is_subagent"]).toBe(true) - }) - - test("includes is_subagent=false on session.created log record", async () => { - const { ctx, logger } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(false) - expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") + handleSessionStatus(evt("session.status", { sessionID: "ses_1", status: { type: "retry", attempt: 1, message: "m", next: 2 } }), ctx) + expect(counters.retry.calls).toHaveLength(1) }) - test("includes is_subagent=true on session.created log record for child session", async () => { - const { ctx, logger } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) - expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(true) - expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("subagent") + test("idle records duration and session totals then clears state", () => { + const { ctx, histograms, logger } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }, 100), ctx) + ctx.tracing.sessionTotals.set("ses_1", { startMs: 100, tokens: 42, cost: 0.5, messages: 3, agent: "build", agentType: "primary" }) + handleSessionStatus(evt("session.status", { sessionID: "ses_1", status: { type: "idle" } }), ctx) + expect(histograms.sessionDuration.calls).toHaveLength(1) + expect(histograms.sessionToken.calls[0]!.value).toBe(42) + expect(histograms.sessionCost.calls[0]!.value).toBe(0.5) + expect(logger.records.at(-1)!.body).toBe("session.idle") + expect(ctx.tracing.sessionTotals.has("ses_1")).toBe(false) }) +}) - test("seeds sessionTotals agent metadata on creation", async () => { +describe("handleUsageUpdated", () => { + test("stores cumulative tokens including cache", () => { const { ctx } = makeCtx() - await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(ctx.sessionTotals.get("ses_1")!.agent).toBe("unknown") - expect(ctx.sessionTotals.get("ses_1")!.agentType).toBe("primary") + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) + handleUsageUpdated(evt("session.usage.updated", { sessionID: "ses_1", cost: 1.25, tokens: tokens(10, 5, 2, 3, 4) }), ctx) + const totals = ctx.tracing.sessionTotals.get("ses_1")! + expect(totals.tokens).toBe(24) + expect(totals.cost).toBe(1.25) }) }) -describe("handleSessionStatus", () => { - test("increments retry counter on retry status", () => { - const { ctx, counters } = makeCtx() - handleSessionStatus(makeSessionStatus("ses_1", { type: "retry", attempt: 1, message: "rate limited", next: 5000 }), ctx) - expect(counters.retry.calls).toHaveLength(1) - expect(counters.retry.calls.at(0)!.value).toBe(1) - expect(counters.retry.calls.at(0)!.attrs["session.id"]).toBe("ses_1") - }) - - test("ignores busy status", () => { - const { ctx, counters } = makeCtx() - handleSessionStatus(makeSessionStatus("ses_1", { type: "busy" }), ctx) - expect(counters.retry.calls).toHaveLength(0) +describe("handleSessionIdle", () => { + test("finalizes via the deprecated idle event", () => { + const { ctx, logger } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }, 100), ctx) + handleSessionIdle(evt("session.idle", { sessionID: "ses_1" }), ctx) + expect(logger.records.at(-1)!.body).toBe("session.idle") }) - test("ignores idle status", () => { - const { ctx, counters } = makeCtx() - handleSessionStatus(makeSessionStatus("ses_1", { type: "idle" }), ctx) - expect(counters.retry.calls).toHaveLength(0) + test("finalizeSession is idempotent", () => { + const { ctx, histograms } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1" }, 100), ctx) + finalizeSession("ses_1", ctx) + finalizeSession("ses_1", ctx) + expect(histograms.sessionDuration.calls).toHaveLength(1) }) }) diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 18851d0..ffba0cd 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -1,635 +1,25 @@ import { describe, test, expect } from "bun:test" -import { context, SpanStatusCode, trace, TraceFlags } from "@opentelemetry/api" -import { - AGENT_NAME, - LLM_MODEL_NAME, - LLM_PROVIDER, - LLM_SYSTEM, - LLM_TOKEN_COUNT_COMPLETION, - LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING, - LLM_TOKEN_COUNT_PROMPT, - LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ, - LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE, - OpenInferenceSpanKind, - SemanticConventions, - SESSION_ID, - TOOL_NAME, -} from "@arizeai/openinference-semantic-conventions" -import type { Span } from "@opentelemetry/api" -import { handleSessionCreated, handleSessionIdle, handleSessionError, handleRunStarted } from "../../src/handlers/session.ts" -import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "../../src/handlers/message.ts" -import { remoteParentContext } from "../../src/trace-context.ts" -import { makeCtx, makeTracer, type SpySpan } from "../helpers.ts" -import type { - EventSessionCreated, - EventSessionIdle, - EventSessionError, - EventMessageUpdated, - EventMessagePartUpdated, -} from "@opencode-ai/sdk" - -const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND - -function makeSessionCreated(sessionID: string, createdAt = 1000, parentID?: string): EventSessionCreated { - return { - type: "session.created", - properties: { info: { id: sessionID, projectID: "proj_test", directory: "/tmp", parentID, time: { created: createdAt } } }, - } as unknown as EventSessionCreated -} - -function makeSessionIdle(sessionID: string): EventSessionIdle { - return { type: "session.idle", properties: { sessionID } } as EventSessionIdle -} - -function makeSessionError(sessionID?: string, error?: { name: string }): EventSessionError { - return { - type: "session.error", - properties: { ...(sessionID !== undefined ? { sessionID } : {}), error }, - } as unknown as EventSessionError -} - -function makeAssistantMessageUpdated(overrides: { - id?: string - parentID?: string - sessionID?: string - modelID?: string - providerID?: string - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - time?: { created: number; completed?: number } - error?: { name: string } -}): EventMessageUpdated { - return { - type: "message.updated", - properties: { - info: { - id: overrides.id ?? "msg_1", - parentID: overrides.parentID ?? "user_1", - role: "assistant", - sessionID: overrides.sessionID ?? "ses_1", - modelID: overrides.modelID ?? "claude-3-5-sonnet", - providerID: overrides.providerID ?? "anthropic", - cost: overrides.cost ?? 0.01, - tokens: overrides.tokens ?? { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: overrides.time ?? { created: 1000, completed: 2000 }, - error: overrides.error, - }, - }, - } as unknown as EventMessageUpdated -} - -function makeToolPartUpdated( - status: "running" | "completed" | "error", - overrides: { sessionID?: string; messageID?: string; callID?: string; tool?: string; startMs?: number; endMs?: number; output?: string } = {}, -): EventMessagePartUpdated { - const sessionID = overrides.sessionID ?? "ses_1" - const messageID = overrides.messageID ?? "msg_1" - const callID = overrides.callID ?? "call_1" - const start = overrides.startMs ?? 1000 - const end = overrides.endMs ?? 2000 - const state = - status === "running" - ? { status: "running", time: { start } } - : status === "completed" - ? { status: "completed", time: { start, end }, output: overrides.output ?? "ok" } - : { status: "error", time: { start, end }, error: "fail" } - return { - type: "message.part.updated", - properties: { part: { type: "tool", sessionID, messageID, callID, tool: overrides.tool ?? "bash", state } }, - } as unknown as EventMessagePartUpdated -} - -describe("session spans", () => { - test("does not start a root trace span on session.created for primary sessions", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_1", 5000), ctx) - expect(tracer.spans).toHaveLength(0) - expect(ctx.sessionSpans.has("ses_1")).toBe(false) - }) - - test("subagent session span carries session.id attribute", () => { - const { ctx, tracer } = makeCtx("proj_test", [], [], true, { team: "platform" }) - handleRunStarted("user_parent", "ses_parent", "build", "prompt", "anthropic/claude", 900, ctx) - handleSessionCreated(makeSessionCreated("ses_1", 1000, "ses_parent"), ctx) - expect(tracer.spans[1]!.attributes["session.id"]).toBe("ses_1") - expect(tracer.spans[1]!.attributes[SESSION_ID]).toBe("ses_1") - expect(tracer.spans[1]!.attributes["team"]).toBe("platform") - }) - - test("run span is tagged as an OpenInference agent span", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - expect(tracer.spans[0]!.attributes[OPENINFERENCE_SPAN_KIND]).toBe(OpenInferenceSpanKind.AGENT) - expect(tracer.spans[0]!.attributes[AGENT_NAME]).toBe("build") - }) - - test("run span carries is_subagent=false for root session", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_root", "ses_root", "build", "prompt", "anthropic/claude", 1000, ctx) - expect(tracer.spans[0]!.attributes["session.is_subagent"]).toBe(false) - }) - - test("session span carries is_subagent=true for subagent session", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) - expect(tracer.spans[0]!.attributes["session.is_subagent"]).toBe(true) - }) - - test("run span is parented to injected remote context", () => { - const { ctx, tracer } = makeCtx() - const rootContext = remoteParentContext("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", undefined) - expect(rootContext).toBeDefined() - ctx.rootContext = () => rootContext! - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - expect(tracer.spans[0]!.parentSpanContext?.traceId).toBe("0af7651916cd43dd8448eb211c80319c") - expect(tracer.spans[0]!.parentSpanContext?.spanId).toBe("b7ad6b7169203331") - }) - - test("run span resolves root context at span creation", () => { - const { ctx, tracer } = makeCtx() - let rootContext = context.active() - ctx.rootContext = () => rootContext - rootContext = trace.setSpanContext(context.active(), { - traceId: "4bf92f3577b34da6a3ce929d0e0e4736", - spanId: "00f067aa0ba902b7", - traceFlags: TraceFlags.SAMPLED, - }) - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - expect(tracer.spans[0]!.parentSpanContext?.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736") - expect(tracer.spans[0]!.parentSpanContext?.spanId).toBe("00f067aa0ba902b7") - }) - - test("ends run span with OK status on session.idle", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - const span = tracer.spans[0]! - expect(span.ended).toBe(true) - expect(span.status.code).toBe(SpanStatusCode.OK) - expect(ctx.runSpans.has("user_1")).toBe(false) - }) - - test("sets session total attributes on the run span before ending on idle", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "build", agentType: "primary" }) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - const span = tracer.spans[0]! - expect(span.attributes["session.total_tokens"]).toBe(250) - expect(span.attributes["session.total_cost_usd"]).toBe(0.05) - expect(span.attributes["session.total_messages"]).toBe(3) - expect(span.attributes[AGENT_NAME]).toBe("build") - expect(span.attributes["agent.type"]).toBe("primary") - }) - - test("ends run span with ERROR status on session.error", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionError(makeSessionError("ses_1", { name: "NetworkError" }), ctx) - const span = tracer.spans[0]! - expect(span.ended).toBe(true) - expect(span.status.code).toBe(SpanStatusCode.ERROR) - expect(ctx.runSpans.has("user_1")).toBe(false) - }) - - test("error message is propagated to run span status", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionError(makeSessionError("ses_1", { name: "TimeoutError" }), ctx) - expect(tracer.spans[0]!.status.message).toBe("TimeoutError") - }) - - test("idle on unknown session does not throw and creates no span", () => { - const { ctx, tracer } = makeCtx() - expect(() => handleSessionIdle(makeSessionIdle("ses_unknown"), ctx)).not.toThrow() - expect(tracer.spans).toHaveLength(0) - }) - - test("session.error with undefined sessionID does not end any span", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionError(makeSessionError(undefined, { name: "UnknownError" }), ctx) - expect(ctx.runSpans.has("user_1")).toBe(true) - expect(tracer.spans[0]!.ended).toBe(false) - }) - - test("subagent span is parented to the active run span", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_parent", "ses_parent", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionCreated(makeSessionCreated("ses_child", 2000, "ses_parent"), ctx) - expect(tracer.spans).toHaveLength(2) - expect(tracer.spans[1]!.name).toBe("opencode.session") - expect(tracer.spans[1]!.parentSpan).toBe(tracer.spans[0]) - }) - - test("subagent span falls back to a root trace when parent run is absent", () => { - const { ctx, tracer } = makeCtx() - expect(() => handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_missing_parent"), ctx)).not.toThrow() - expect(tracer.spans).toHaveLength(1) - expect(tracer.spans[0]!.parentSpan).toBeUndefined() - expect(tracer.spans[0]!.parentSpanContext).toBeUndefined() - }) - - test("late child spans reuse the ended run trace context", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 1000, ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - startMessageSpan("ses_1", "msg_late", "user_1", "claude", "anthropic", 2000, ctx) - expect(tracer.spans).toHaveLength(2) - expect(tracer.spans[1]!.parentSpanContext).toBeDefined() - expect(tracer.spans[1]!.parentSpanContext?.spanId).toBe(tracer.spans[0]!.spanContext().spanId) - expect(tracer.spans[1]!.parentSpanContext?.traceId).toBe(tracer.spans[0]!.spanContext().traceId) - }) - - test("late child events stay on the previous run after a new run starts", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt one", "anthropic/claude", 1000, ctx) - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1100, ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - handleRunStarted("user_2", "ses_1", "build", "prompt two", "anthropic/claude", 2000, ctx) - handleMessagePartUpdated(makeToolPartUpdated("running", { sessionID: "ses_1", messageID: "msg_1", callID: "call_late" }), ctx) - expect(tracer.spans).toHaveLength(4) - expect(tracer.spans[3]!.name).toBe("opencode.tool.bash") - expect(tracer.spans[3]!.parentSpanContext?.spanId).toBe(tracer.spans[0]!.spanContext().spanId) - expect(tracer.spans[3]!.parentSpanContext?.spanId).not.toBe(tracer.spans[2]!.spanContext().spanId) - }) -}) - -describe("tool spans", () => { - test("starts a tool span on running status", () => { - const { ctx, tracer } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1000 }), ctx) - expect(tracer.spans).toHaveLength(1) - expect(tracer.spans[0]!.name).toBe("opencode.tool.bash") - expect(tracer.spans[0]!.startTime).toBe(1000) - expect(ctx.pendingToolSpans.has("ses_1:call_1")).toBe(true) - }) - - test("tool span carries tool.name attribute", () => { - const { ctx, tracer } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) - handleMessagePartUpdated(makeToolPartUpdated("running", { tool: "read_file" }), ctx) - expect(tracer.spans[0]!.attributes["tool.name"]).toBe("read_file") - expect(tracer.spans[0]!.attributes[TOOL_NAME]).toBe("read_file") - expect(tracer.spans[0]!.attributes[OPENINFERENCE_SPAN_KIND]).toBe(OpenInferenceSpanKind.TOOL) - expect(tracer.spans[0]!.attributes[AGENT_NAME]).toBe("build") - expect(tracer.spans[0]!.attributes["agent.type"]).toBe("primary") - }) - - test("ends tool span with OK status on completion", () => { - const { ctx, tracer } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed", { endMs: 2000 }), ctx) - const span = tracer.spans[0]! - expect(span.ended).toBe(true) - expect(span.status.code).toBe(SpanStatusCode.OK) - expect(span.endTime).toBe(2000) - }) - - test("ends tool span with ERROR status on error", () => { - const { ctx, tracer } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("error"), ctx) - const span = tracer.spans[0]! - expect(span.ended).toBe(true) - expect(span.status.code).toBe(SpanStatusCode.ERROR) - }) - - test("tool span result_size_bytes matches exact byte length of multibyte output", () => { - const { ctx, tracer } = makeCtx() - const multibyte = "こんにちは" - const expectedBytes = Buffer.byteLength(multibyte, "utf8") - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed", { output: multibyte }), ctx) - expect(tracer.spans[0]!.attributes["tool.result_size_bytes"]).toBe(expectedBytes) - }) - - test("tool span error attr set on error status", () => { - const { ctx, tracer } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("error"), ctx) - expect(tracer.spans[0]!.attributes["tool.error"]).toBe("fail") - }) - - test("tool span removed from pendingToolSpans after completion", () => { - const { ctx } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) - expect(ctx.pendingToolSpans.size).toBe(0) - }) - - test("tool span started even when completed arrives without prior running (out-of-order)", () => { - const { ctx, tracer } = makeCtx() - handleMessagePartUpdated(makeToolPartUpdated("completed", { startMs: 500, endMs: 1500 }), ctx) - expect(tracer.spans).toHaveLength(1) - expect(tracer.spans[0]!.ended).toBe(true) - expect(tracer.spans[0]!.status.code).toBe(SpanStatusCode.OK) - }) - - test("tool span is parented to session span when available", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 900, ctx) - handleMessagePartUpdated(makeToolPartUpdated("running", { sessionID: "ses_1" }), ctx) - expect(tracer.spans).toHaveLength(2) - expect(tracer.spans[1]!.name).toBe("opencode.tool.bash") - expect(tracer.spans[1]!.parentSpan).toBe(tracer.spans[0]) - }) - - test("out-of-order tool span is parented to session span when available", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 900, ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed", { sessionID: "ses_1", startMs: 500, endMs: 1500 }), ctx) - expect(tracer.spans).toHaveLength(2) - expect(tracer.spans[1]!.name).toBe("opencode.tool.bash") - expect(tracer.spans[1]!.parentSpan).toBe(tracer.spans[0]) - }) -}) - -describe("message (LLM) spans", () => { - test("startMessageSpan creates an llm span", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx, "build") - expect(tracer.spans).toHaveLength(1) - expect(tracer.spans[0]!.name).toBe("opencode.llm") - expect(ctx.messageSpans.has("ses_1:msg_1")).toBe(true) - expect(ctx.llmRequestContexts.get("ses_1:user_1")?.[0]).toMatchObject({ - messageID: "msg_1", - agent: "build", - modelID: "claude-3-5-sonnet", - providerID: "anthropic", - }) - }) - - test("startMessageSpan sets OpenInference LLM attributes", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-sonnet-4", "amazon-bedrock", 1000, ctx) - expect(tracer.spans[0]!.attributes[OPENINFERENCE_SPAN_KIND]).toBe(OpenInferenceSpanKind.LLM) - expect(tracer.spans[0]!.attributes[LLM_SYSTEM]).toBe("amazon-bedrock") - expect(tracer.spans[0]!.attributes[LLM_PROVIDER]).toBe("amazon-bedrock") - expect(tracer.spans[0]!.attributes["gen_ai.provider.name"]).toBe("aws.bedrock") - expect(tracer.spans[0]!.attributes[LLM_MODEL_NAME]).toBe("claude-sonnet-4") - }) - - test("startMessageSpan is a no-op when span already exists for sessionID:messageID", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - expect(tracer.spans).toHaveLength(1) - }) - - test("handleMessageUpdated ends message span on completion", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", time: { created: 1000, completed: 2000 } }), ctx) - const span = tracer.spans[0]! - expect(span.ended).toBe(true) - expect(span.endTime).toBe(2000) - expect(ctx.messageSpans.has("ses_1:msg_1")).toBe(false) - expect(ctx.llmRequestContexts.has("ses_1:user_1")).toBe(false) - }) - - test("an older completion does not remove a newer request context", () => { - const { ctx } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - startMessageSpan("ses_1", "msg_2", "user_1", "claude", "anthropic", 1500, ctx) - - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", time: { created: 1000, completed: 2000 } }), ctx) - - expect(ctx.llmRequestContexts.get("ses_1:user_1")?.map(request => request.messageID)).toEqual(["msg_2"]) - }) - - test("handleMessageUpdated sets OK status on success", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1" }), ctx) - expect(tracer.spans[0]!.status.code).toBe(SpanStatusCode.OK) - }) - - test("handleMessageUpdated sets ERROR status on api error", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", error: { name: "RateLimitError" } }), ctx) - expect(tracer.spans[0]!.status.code).toBe(SpanStatusCode.ERROR) - expect(tracer.spans[0]!.status.message).toBe("RateLimitError") - }) - - test("handleMessageUpdated sets OpenInference token attributes on span", () => { - const { ctx, tracer } = makeCtx() - startMessageSpan("ses_1", "msg_1", "user_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "subagent" }) - handleMessageUpdated( - makeAssistantMessageUpdated({ - id: "msg_1", - tokens: { input: 200, output: 80, reasoning: 10, cache: { read: 30, write: 5 } }, - }), +import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" +import { handleStepStarted } from "../../src/handlers/step.ts" +import { handleToolInputStarted } from "../../src/handlers/tool.ts" +import { makeCtx, evt } from "../helpers.ts" + +describe("trace nesting", () => { + test("run -> step -> tool spans form a single chain", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", agent: "build" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleStepStarted( + evt("session.step.started", { sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "anthropic" } }), ctx, ) - const span = tracer.spans[0]! - expect(span.attributes[LLM_TOKEN_COUNT_PROMPT]).toBe(200) - expect(span.attributes[LLM_TOKEN_COUNT_COMPLETION]).toBe(80) - expect(span.attributes[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]).toBe(10) - expect(span.attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]).toBe(30) - expect(span.attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]).toBe(5) - expect(span.attributes[AGENT_NAME]).toBe("review") - expect(span.attributes["agent.type"]).toBe("subagent") - }) - - test("handleMessageUpdated no-ops span handling when no span exists for messageID", () => { - const { ctx, tracer } = makeCtx() - const spansBefore = tracer.spans.length - const mapSizeBefore = ctx.messageSpans.size - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_no_span" }), ctx) - expect(tracer.spans).toHaveLength(spansBefore) - expect(ctx.messageSpans.size).toBe(mapSizeBefore) - }) - - test("message span is parented to session span when available", () => { - const { ctx, tracer } = makeCtx() - handleRunStarted("user_1", "ses_1", "build", "prompt", "anthropic/claude", 900, ctx) - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - expect(tracer.spans).toHaveLength(2) - expect(tracer.spans[1]!.name).toBe("opencode.llm") - expect(tracer.spans[1]!.parentSpan).toBe(tracer.spans[0]) - }) -}) - -describe("orphaned span cleanup", () => { - test("pending tool spans are ended with ERROR on session.idle", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("running", { sessionID: "ses_1" }), ctx) - expect(ctx.pendingToolSpans.size).toBe(1) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.pendingToolSpans.size).toBe(0) - const toolSpan = tracer.spans.find(s => s.name.startsWith("opencode.tool"))! - expect(toolSpan.ended).toBe(true) - expect(toolSpan.status.code).toBe(SpanStatusCode.ERROR) - }) - - test("pending tool spans for other sessions are not swept", () => { - const { ctx } = makeCtx() - const t = makeTracer() - const span = t.startSpan("tool") as unknown as Span - ctx.pendingToolSpans.set("ses_other:call_1", { tool: "bash", sessionID: "ses_other", startMs: 0, span }) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.pendingToolSpans.has("ses_other:call_1")).toBe(true) - }) - - test("pending tool spans are ended with ERROR on session.error", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("running", { sessionID: "ses_1" }), ctx) - handleSessionError(makeSessionError("ses_1"), ctx) - expect(ctx.pendingToolSpans.size).toBe(0) - const toolSpan = tracer.spans.find(s => s.name.startsWith("opencode.tool"))! - expect(toolSpan.ended).toBe(true) - expect(toolSpan.status.code).toBe(SpanStatusCode.ERROR) - }) - - test("pending message spans are ended with ERROR on session.idle", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - startMessageSpan("ses_1", "msg_orphan", "user_1", "claude", "anthropic", 1000, ctx) - handleSessionIdle(makeSessionIdle("ses_1"), ctx) - expect(ctx.messageSpans.has("ses_1:msg_orphan")).toBe(false) - expect(ctx.llmRequestContexts.has("ses_1:user_1")).toBe(false) - const msgSpan = tracer.spans.find(s => s.name === "opencode.llm")! - expect(msgSpan.ended).toBe(true) - expect(msgSpan.status.code).toBe(SpanStatusCode.ERROR) - }) - - test("pending message spans are ended with ERROR on session.error", () => { - const { ctx, tracer } = makeCtx() - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - startMessageSpan("ses_1", "msg_orphan", "user_1", "claude", "anthropic", 1000, ctx) - handleSessionError(makeSessionError("ses_1"), ctx) - expect(ctx.messageSpans.has("ses_1:msg_orphan")).toBe(false) - const msgSpan = tracer.spans.find(s => s.name === "opencode.llm")! - expect(msgSpan.ended).toBe(true) - expect(msgSpan.status.code).toBe(SpanStatusCode.ERROR) - }) -}) - -describe("OPENCODE_DISABLE_TRACES=session", () => { - test("no session span is started", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(tracer.spans).toHaveLength(0) - expect(ctx.sessionSpans.has("ses_1")).toBe(false) - }) - - test("session counter metric still fires", () => { - const { ctx, counters } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(counters.session.calls).toHaveLength(1) - }) - - test("session.created log record still emitted", () => { - const { ctx, logger } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(logger.records.find(r => r.body === "session.created")).toBeDefined() - }) - - test("session.idle does not throw when no session span exists", () => { - const { ctx } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(() => handleSessionIdle(makeSessionIdle("ses_1"), ctx)).not.toThrow() - }) - - test("session.error does not throw when no session span exists", () => { - const { ctx } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(() => handleSessionError(makeSessionError("ses_1"), ctx)).not.toThrow() - }) - - test("llm spans become root spans (no parent) when session traces disabled but llm enabled", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["session"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - expect(tracer.spans).toHaveLength(1) - expect(tracer.spans[0]!.name).toBe("opencode.llm") - expect(tracer.spans[0]!.parentSpan).toBeUndefined() - }) -}) - -describe("OPENCODE_DISABLE_TRACES=llm", () => { - test("startMessageSpan is a no-op", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["llm"]) - startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) - expect(tracer.spans).toHaveLength(0) - expect(ctx.messageSpans.has("msg_1")).toBe(false) - }) - - test("token counter metrics still fire", () => { - const { ctx, counters } = makeCtx("proj_test", [], ["llm"]) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1" }), ctx) - expect(counters.token.calls.length).toBeGreaterThan(0) - }) - - test("cost counter metric still fires", () => { - const { ctx, counters } = makeCtx("proj_test", [], ["llm"]) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1", cost: 0.05 }), ctx) - expect(counters.cost.calls).toHaveLength(1) - }) - - test("api_request log record still emitted", () => { - const { ctx, logger } = makeCtx("proj_test", [], ["llm"]) - handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1" }), ctx) - expect(logger.records.find(r => r.body === "api_request")).toBeDefined() - }) - - test("handleMessageUpdated does not throw when no message span exists", () => { - const { ctx } = makeCtx("proj_test", [], ["llm"]) - expect(() => handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_1" }), ctx)).not.toThrow() - }) - - test("session spans still created when only llm disabled", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["llm"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(tracer.spans).toHaveLength(0) - }) -}) - -describe("OPENCODE_DISABLE_TRACES=tool", () => { - test("no tool span started on running status", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["tool"]) - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - expect(tracer.spans).toHaveLength(0) - }) - - test("pendingToolSpans entry still stored for histogram timing", () => { - const { ctx } = makeCtx("proj_test", [], ["tool"]) - handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1000 }), ctx) - expect(ctx.pendingToolSpans.has("ses_1:call_1")).toBe(true) - expect(ctx.pendingToolSpans.get("ses_1:call_1")!.startMs).toBe(1000) - expect(ctx.pendingToolSpans.get("ses_1:call_1")!.span).toBeUndefined() - }) - - test("tool.duration histogram still records on completion", () => { - const { ctx, histograms } = makeCtx("proj_test", [], ["tool"]) - handleMessagePartUpdated(makeToolPartUpdated("running", { startMs: 1000 }), ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed", { startMs: 1000, endMs: 1500 }), ctx) - expect(histograms.tool.calls).toHaveLength(1) - expect(histograms.tool.calls[0]!.value).toBe(500) - }) - - test("tool_result log record still emitted on completion", () => { - const { ctx, logger } = makeCtx("proj_test", [], ["tool"]) - handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) - handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) - expect(logger.records.find(r => r.body === "tool_result")).toBeDefined() - }) - - test("no tool span created for out-of-order completed event", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["tool"]) - handleMessagePartUpdated(makeToolPartUpdated("completed", { startMs: 500, endMs: 1500 }), ctx) - expect(tracer.spans).toHaveLength(0) - }) - - test("session spans still created when only tool disabled", () => { - const { ctx, tracer } = makeCtx("proj_test", [], ["tool"]) - handleSessionCreated(makeSessionCreated("ses_1"), ctx) - expect(tracer.spans).toHaveLength(0) + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", name: "read" }), ctx) + + const run = tracer.spans.find((s) => s.name === "opencode.session")! + const step = tracer.spans.find((s) => s.name === "opencode.llm")! + const tool = tracer.spans.find((s) => s.name === "opencode.tool.read")! + expect(run.parentSpan).toBeUndefined() + expect(step.parentSpan).toBe(run) + expect(tool.parentSpan).toBe(step) }) }) diff --git a/tests/handlers/step.test.ts b/tests/handlers/step.test.ts new file mode 100644 index 0000000..9b9ba01 --- /dev/null +++ b/tests/handlers/step.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect } from "bun:test" +import { SpanStatusCode } from "@opentelemetry/api" +import { handleStepEnded, handleStepFailed, handleStepStarted } from "../../src/handlers/step.ts" +import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" +import { makeCtx, evt, tokens } from "../helpers.ts" + +function seedSession(ctx: ReturnType["ctx"], sessionID = "ses_1") { + handleSessionCreated(evt("session.created", { sessionID, agent: "build" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID }), ctx) +} + +function stepStarted(sessionID = "ses_1", assistantMessageID = "msg_1") { + return evt("session.step.started", { + sessionID, + assistantMessageID, + agent: "build", + model: { id: "claude-sonnet", providerID: "anthropic" }, + started: 1500, + }) +} + +describe("handleStepStarted", () => { + test("starts an LLM span parented to the run span", () => { + const { ctx, tracer } = makeCtx() + seedSession(ctx) + handleStepStarted(stepStarted(), ctx) + const llm = tracer.spans.find((s) => s.name === "opencode.llm")! + const run = tracer.spans.find((s) => s.name === "opencode.session")! + expect(llm.parentSpan).toBe(run) + expect(llm.attributes["llm.model_name"]).toBe("claude-sonnet") + expect(llm.attributes["gen_ai.provider.name"]).toBe("anthropic") + expect(ctx.tracing.activeLlm.get("ses_1")!.providerID).toBe("anthropic") + }) +}) + +describe("handleStepEnded", () => { + test("records token, cost, cache, message, and model metrics and ends the span", () => { + const { ctx, tracer, counters, logger } = makeCtx() + seedSession(ctx) + handleStepStarted(stepStarted(), ctx) + handleStepEnded( + evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "stop", cost: 0.25, tokens: tokens(10, 5, 2, 3, 0) }, 2000), + ctx, + ) + const tokenTypes = counters.token.calls.map((c) => c.attrs["type"]) + expect(tokenTypes).toEqual(["input", "output", "reasoning", "cacheRead", "cacheCreation"]) + expect(counters.cost.calls[0]!.value).toBe(0.25) + expect(counters.cache.calls.map((c) => c.attrs["type"])).toEqual(["cacheRead"]) + expect(counters.message.calls).toHaveLength(1) + expect(counters.modelUsage.calls[0]!.attrs["provider"]).toBe("anthropic") + expect(logger.records.at(-1)!.body).toBe("api_request") + const llm = tracer.spans.find((s) => s.name === "opencode.llm")! + expect(llm.ended).toBe(true) + expect(llm.status.code).toBe(SpanStatusCode.OK) + expect(llm.attributes["llm.token_count.total"]).toBe(20) + }) + + test("counts a message once across multiple steps", () => { + const { ctx, counters } = makeCtx() + seedSession(ctx) + handleStepStarted(stepStarted(), ctx) + handleStepEnded(evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "tool-calls", cost: 0.1, tokens: tokens() }), ctx) + handleStepStarted(stepStarted(), ctx) + handleStepEnded(evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "stop", cost: 0.1, tokens: tokens() }), ctx) + expect(counters.message.calls).toHaveLength(1) + }) +}) + +describe("handleStepFailed", () => { + test("records usage, ends the span with error, and emits api_error", () => { + const { ctx, tracer, counters, logger } = makeCtx() + seedSession(ctx) + handleStepStarted(stepStarted(), ctx) + handleStepFailed( + evt("session.step.failed", { sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "ProviderError", message: "boom" }, cost: 0.1, tokens: tokens() }), + ctx, + ) + expect(counters.token.calls.length).toBeGreaterThan(0) + const llm = tracer.spans.find((s) => s.name === "opencode.llm")! + expect(llm.status.code).toBe(SpanStatusCode.ERROR) + expect(logger.records.at(-1)!.body).toBe("api_error") + expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ProviderError: boom") + }) +}) diff --git a/tests/handlers/tool.test.ts b/tests/handlers/tool.test.ts new file mode 100644 index 0000000..89d0de0 --- /dev/null +++ b/tests/handlers/tool.test.ts @@ -0,0 +1,77 @@ +import { describe, test, expect } from "bun:test" +import { SpanStatusCode } from "@opentelemetry/api" +import { handleToolCalled, handleToolFailed, handleToolInputStarted, handleToolSuccess } from "../../src/handlers/tool.ts" +import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" +import { makeCtx, evt } from "../helpers.ts" + +function seed(ctx: ReturnType["ctx"], sessionID = "ses_1") { + handleSessionCreated(evt("session.created", { sessionID, agent: "build" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID }), ctx) +} + +function inputStarted(id = "call_1", name = "bash", sessionID = "ses_1", created = 1000) { + return evt("session.tool.input.started", { sessionID, assistantMessageID: "msg_1", id, name }, created) +} + +describe("handleToolInputStarted", () => { + test("starts a tool span named after the tool", () => { + const { ctx, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + const span = tracer.spans.find((s) => s.name.startsWith("opencode.tool."))! + expect(span.name).toBe("opencode.tool.bash") + expect(ctx.tracing.toolMeta.get("call_1")!.tool).toBe("bash") + }) +}) + +describe("handleToolCalled", () => { + test("attaches parameters and detects a git commit", () => { + const { ctx, counters, logger, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: { command: "git commit -m 'x'" }, executed: true }), ctx) + expect(counters.commit.calls).toHaveLength(1) + expect(logger.records.at(-1)!.body).toBe("commit") + const span = tracer.spans.find((s) => s.name === "opencode.tool.bash")! + expect(span.attributes["input.value"]).toContain("git commit") + }) + + test("ignores non-commit shell commands", () => { + const { ctx, counters } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: { command: "ls -la" }, executed: true }), ctx) + expect(counters.commit.calls).toHaveLength(0) + }) +}) + +describe("handleToolSuccess", () => { + test("records duration, sets output, and emits tool_result", () => { + const { ctx, histograms, logger, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted("call_1", "read", "ses_1", 1000), ctx) + handleToolSuccess(evt("session.tool.success", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", content: [{ type: "text", text: "file body" }], executed: true }, 1250), ctx) + expect(histograms.tool.calls[0]!.value).toBe(250) + expect(histograms.tool.calls[0]!.attrs["success"]).toBe(true) + const record = logger.records.at(-1)! + expect(record.body).toBe("tool_result") + expect(record.attributes?.["duration_ms"]).toBe(250) + const span = tracer.spans.find((s) => s.name === "opencode.tool.read")! + expect(span.ended).toBe(true) + expect(span.status.code).toBe(SpanStatusCode.OK) + expect(span.attributes["output.value"]).toBe("file body") + }) +}) + +describe("handleToolFailed", () => { + test("records an error result and ends the span with error", () => { + const { ctx, histograms, logger, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + handleToolFailed(evt("session.tool.failed", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", error: { type: "ToolError", message: "nope" }, executed: true }, 1200), ctx) + expect(histograms.tool.calls[0]!.attrs["success"]).toBe(false) + expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ToolError: nope") + const span = tracer.spans.find((s) => s.name === "opencode.tool.bash")! + expect(span.status.code).toBe(SpanStatusCode.ERROR) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index a1594b9..29468c7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ -import type { HandlerContext, Instruments } from "../src/types.ts" +import type { HandlerContext, Instruments, TracingState, OpenCodeEvent } from "../src/types.ts" import type { LogRecord } from "@opentelemetry/api-logs" -import type { Counter, Gauge, Histogram, Span, SpanOptions, Tracer, Context, SpanContext, SpanStatus, Attributes } from "@opentelemetry/api" +import type { Counter, Histogram, Span, SpanOptions, Tracer, Context, SpanContext, SpanStatus, Attributes } from "@opentelemetry/api" import { ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api" export type SpyCounter = { @@ -13,11 +13,6 @@ export type SpyHistogram = { record(value: number, attrs?: Record): void } -export type SpyGauge = { - calls: Array<{ value: number; attrs: Record }> - record(value: number, attrs?: Record): void -} - export type SpyLogger = { records: LogRecord[] emit(record: LogRecord): void @@ -63,11 +58,6 @@ function makeHistogram(): SpyHistogram { return spy } -function makeGauge(): SpyGauge { - const spy: SpyGauge = { calls: [], record(v, a = {}) { spy.calls.push({ value: v, attrs: a }) } } - return spy -} - function makeLogger(): SpyLogger { const spy: SpyLogger = { records: [], emit(r) { spy.records.push(r) } } return spy @@ -143,13 +133,38 @@ export function makeTracer(): SpyTracer { return tracer } +/** Builds a fresh tracing correlation state for a mock context. */ +export function makeTracingState(): TracingState { + return { + seenEvents: new Set(), + runSpans: new Map(), + runSpanContexts: new Map(), + stepSpans: new Map(), + stepSpanContexts: new Map(), + toolSpans: new Map(), + toolSpanContexts: new Map(), + toolMeta: new Map(), + stepMeta: new Map(), + sessionTotals: new Map(), + countedSessions: new Set(), + countedMessages: new Set(), + pendingPrompts: new Map(), + pendingPermissions: new Map(), + activeLlm: new Map(), + } +} + +/** Builds a mock OpenCode V2 event with an id, type, timestamp, and data payload. */ +export function evt(type: string, data: Record, created = 1000, id = `${type}:1`): any { + return { id, type, created, data } as unknown as OpenCodeEvent +} + export type MockContext = { ctx: HandlerContext counters: { session: SpyCounter token: SpyCounter cost: SpyCounter - lines: SpyCounter commit: SpyCounter cache: SpyCounter message: SpyCounter @@ -160,11 +175,8 @@ export type MockContext = { histograms: { tool: SpyHistogram sessionDuration: SpyHistogram - } - gauges: { sessionToken: SpyHistogram sessionCost: SpyHistogram - linesTotal: SpyGauge } logger: SpyLogger pluginLog: SpyPluginLog @@ -181,7 +193,6 @@ export function makeCtx( const session = makeCounter() const token = makeCounter() const cost = makeCounter() - const lines = makeCounter() const commit = makeCounter() const cache = makeCounter() const message = makeCounter() @@ -190,9 +201,8 @@ export function makeCtx( const subtask = makeCounter() const toolHistogram = makeHistogram() const sessionDurationHistogram = makeHistogram() - const sessionTokenGauge = makeHistogram() - const sessionCostGauge = makeHistogram() - const linesTotalGauge = makeGauge() + const sessionTokenHistogram = makeHistogram() + const sessionCostHistogram = makeHistogram() const logger = makeLogger() const pluginLog = makePluginLog() const tracer = makeTracer() @@ -201,15 +211,13 @@ export function makeCtx( sessionCounter: session as unknown as Counter, tokenCounter: token as unknown as Counter, costCounter: cost as unknown as Counter, - linesCounter: lines as unknown as Counter, - linesTotalGauge: linesTotalGauge as unknown as Gauge, commitCounter: commit as unknown as Counter, toolDurationHistogram: toolHistogram as unknown as Histogram, cacheCounter: cache as unknown as Counter, sessionDurationHistogram: sessionDurationHistogram as unknown as Histogram, messageCounter: message as unknown as Counter, - sessionTokenGauge: sessionTokenGauge as unknown as Histogram, - sessionCostGauge: sessionCostGauge as unknown as Histogram, + sessionTokenHistogram: sessionTokenHistogram as unknown as Histogram, + sessionCostHistogram: sessionCostHistogram as unknown as Histogram, modelUsageCounter: modelUsage as unknown as Counter, retryCounter: retry as unknown as Counter, subtaskCounter: subtask as unknown as Counter, @@ -223,36 +231,28 @@ export function makeCtx( }, instruments, commonAttrs: { "project.id": projectID, ...extraCommonAttrs }, - pendingToolSpans: new Map(), - pendingPermissions: new Map(), - sessionTotals: new Map(), - sessionDiffTotals: new Map(), disabledMetrics: new Set(disabledMetrics), disabledTraces: new Set(disabledTraces), tracer: tracer as unknown as Tracer, tracePrefix: "opencode.", rootContext: () => ROOT_CONTEXT, - runSpans: new Map(), - runSpanContexts: new Map(), - activeRuns: new Map(), - assistantRuns: new Map(), - pendingRuns: new Map(), - runInputs: new Map(), - sessionSpans: new Map(), - sessionSpanContexts: new Map(), - messageSpans: new Map(), - messageOutputs: new Map(), - llmRequestContexts: new Map(), + tracing: makeTracingState(), tracePropagationProviders: new Set(), } return { ctx, - counters: { session, token, cost, lines, commit, cache, message, modelUsage, retry, subtask }, - histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram }, - gauges: { sessionToken: sessionTokenGauge, sessionCost: sessionCostGauge, linesTotal: linesTotalGauge }, + counters: { session, token, cost, commit, cache, message, modelUsage, retry, subtask }, + histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram, sessionToken: sessionTokenHistogram, sessionCost: sessionCostHistogram }, logger, pluginLog, tracer, } } + +/** Returns a minimal well-formed V2 token usage sample. */ +export function tokens(input = 10, output = 5, reasoning = 0, cacheRead = 0, cacheWrite = 0) { + return { input, output, reasoning, cache: { read: cacheRead, write: cacheWrite } } +} + +export type { Span } diff --git a/tests/util.test.ts b/tests/util.test.ts index 04fada1..9911b90 100644 --- a/tests/util.test.ts +++ b/tests/util.test.ts @@ -1,150 +1,52 @@ import { describe, test, expect } from "bun:test" -import { errorSummary, genAiProviderName, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../src/util.ts" import { MAX_PENDING } from "../src/types.ts" +import { errorSummary, genAiProviderName, markSeen, modelRef, setBoundedMap, totalTokens } from "../src/util.ts" describe("errorSummary", () => { - test("returns 'unknown' for undefined", () => { + test("formats structured errors", () => { + expect(errorSummary({ type: "ProviderError", message: "boom" })).toBe("ProviderError: boom") expect(errorSummary(undefined)).toBe("unknown") }) - - test("returns name when no data", () => { - expect(errorSummary({ name: "APIError" })).toBe("APIError") - }) - - test("returns name when data has no message", () => { - expect(errorSummary({ name: "APIError", data: { code: 500 } })).toBe("APIError") - }) - - test("returns name: message when data has message", () => { - expect(errorSummary({ name: "APIError", data: { message: "rate limited" } })).toBe( - "APIError: rate limited", - ) - }) - - test("returns name when data is a primitive", () => { - expect(errorSummary({ name: "APIError", data: "oops" })).toBe("APIError") - }) }) describe("genAiProviderName", () => { - test.each([ - ["amazon-bedrock", "aws.bedrock"], - ["azure", "azure.ai.openai"], - ["azure-cognitive-services", "azure.ai.openai"], - ["google", "gcp.gemini"], - ["google-vertex", "gcp.vertex_ai"], - ["google-vertex-anthropic", "gcp.vertex_ai"], - ["mistral", "mistral_ai"], - ["xai", "x_ai"], - ])("maps %s to %s", (providerID, expected) => { - expect(genAiProviderName(providerID)).toBe(expected) + test("maps known providers and preserves unknown ones", () => { + expect(genAiProviderName("amazon-bedrock")).toBe("aws.bedrock") + expect(genAiProviderName("custom")).toBe("custom") }) +}) - test("preserves provider IDs without a canonical mapping", () => { - expect(genAiProviderName("openrouter")).toBe("openrouter") - expect(genAiProviderName("custom-gateway")).toBe("custom-gateway") +describe("totalTokens", () => { + test("sums input, output, and reasoning only", () => { + expect(totalTokens({ input: 1, output: 2, reasoning: 3, cache: { read: 10, write: 10 } })).toBe(6) + expect(totalTokens(undefined)).toBe(0) }) }) -describe("setBoundedMap", () => { - test("adds an entry to the map", () => { - const map = new Map() - setBoundedMap(map, "a", 1) - expect(map.get("a")).toBe(1) +describe("modelRef", () => { + test("formats provider/id", () => { + expect(modelRef({ providerID: "anthropic", id: "claude" })).toBe("anthropic/claude") + expect(modelRef(undefined)).toBe("unknown") }) +}) - test("evicts the oldest entry when at capacity", () => { - const map = new Map() - for (let i = 0; i < MAX_PENDING; i++) { - setBoundedMap(map, `key-${i}`, i) - } - expect(map.size).toBe(MAX_PENDING) - expect(map.has("key-0")).toBe(true) - - setBoundedMap(map, "overflow", 999) +describe("setBoundedMap", () => { + test("evicts the oldest entry at capacity", () => { + const map = new Map() + for (let i = 0; i < MAX_PENDING; i++) setBoundedMap(map, i, i) expect(map.size).toBe(MAX_PENDING) - expect(map.has("key-0")).toBe(false) - expect(map.has("overflow")).toBe(true) - }) - - test("does not evict when below capacity", () => { - const map = new Map() - setBoundedMap(map, "a", 1) - setBoundedMap(map, "b", 2) - expect(map.size).toBe(2) - expect(map.has("a")).toBe(true) - }) - - test("overwrites an existing key without evicting", () => { - const map = new Map() - setBoundedMap(map, "a", 1) - setBoundedMap(map, "a", 2) - expect(map.get("a")).toBe(2) - expect(map.size).toBe(1) - }) - - test("updates an existing key at capacity without evicting another entry", () => { - const map = new Map() - for (let i = 0; i < MAX_PENDING; i++) { - setBoundedMap(map, `key-${i}`, i) - } - - setBoundedMap(map, "key-10", 1000) - + setBoundedMap(map, MAX_PENDING, MAX_PENDING) expect(map.size).toBe(MAX_PENDING) - expect(map.get("key-10")).toBe(1000) - expect(map.has("key-0")).toBe(true) + expect(map.has(0)).toBe(false) }) }) -describe("isMetricEnabled", () => { - test("returns true when disabled set is empty", () => { - expect(isMetricEnabled("session.count", { disabledMetrics: new Set() })).toBe(true) - }) - - test("returns false when metric is in the disabled set", () => { - expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["session.count"]) })).toBe(false) - }) - - test("returns true when a different metric is disabled", () => { - expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["cache.count"]) })).toBe(true) - }) - - test("is case-sensitive — does not match mismatched case", () => { - expect(isMetricEnabled("session.count", { disabledMetrics: new Set(["Session.Count"]) })).toBe(true) - }) - - test("unknown metric names in disabled set do not affect known metrics", () => { - expect(isMetricEnabled("retry.count", { disabledMetrics: new Set(["does.not.exist"]) })).toBe(true) - }) -}) - -describe("isTraceEnabled", () => { - test("returns true when disabled set is empty", () => { - expect(isTraceEnabled("session", { disabledTraces: new Set() })).toBe(true) - }) - - test("returns false when trace type is in the disabled set", () => { - expect(isTraceEnabled("session", { disabledTraces: new Set(["session"]) })).toBe(false) - }) - - test("returns false for llm when llm is disabled", () => { - expect(isTraceEnabled("llm", { disabledTraces: new Set(["llm"]) })).toBe(false) - }) - - test("returns false for tool when tool is disabled", () => { - expect(isTraceEnabled("tool", { disabledTraces: new Set(["tool"]) })).toBe(false) - }) - - test("returns true when a different trace type is disabled", () => { - expect(isTraceEnabled("session", { disabledTraces: new Set(["tool"]) })).toBe(true) - }) - - test("is case-sensitive — does not match mismatched case", () => { - expect(isTraceEnabled("session", { disabledTraces: new Set(["Session"]) })).toBe(true) - }) - - test("unknown trace names in disabled set do not affect known types", () => { - expect(isTraceEnabled("llm", { disabledTraces: new Set(["does_not_exist"]) })).toBe(true) +describe("markSeen", () => { + test("records unique event ids", () => { + const seen = new Set() + markSeen(seen, "a") + markSeen(seen, "b") + expect(seen.has("a")).toBe(true) + expect(seen.size).toBe(2) }) }) From 86b2bc00419fa7bfda2ae31ac0a786ddb2c3b32f Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:03:07 -0400 Subject: [PATCH 02/13] fix(v2): restore telemetry parity and isolate shared exporters Reject conflicting location telemetry configuration and attribute events using the observed session project. Consume durable prompt and retry events, restore LLM output and subtask logs, and count git commits only after successful tool completion. Bound correlation sets and preserve cost-only failures. --- AGENTS.md | 9 +-- CONTRIBUTING.md | 14 ++--- README.md | 54 +++++++++++------ src/handlers/chat-headers.ts | 3 +- src/handlers/session.ts | 61 +++++++++++++++++-- src/handlers/step.ts | 79 +++++++++++++++--------- src/handlers/tool.ts | 49 +++++++-------- src/otel.ts | 6 +- src/plugin.ts | 106 +++++++++++++++++---------------- src/state.ts | 21 ++++++- src/types.ts | 4 ++ src/util.ts | 21 ++++++- tests/chat-headers.test.ts | 9 +++ tests/handlers/session.test.ts | 30 +++++++++- tests/handlers/step.test.ts | 14 ++++- tests/handlers/tool.test.ts | 16 ++++- tests/helpers.ts | 2 + tests/state.test.ts | 48 +++++++++++++++ 18 files changed, 396 insertions(+), 150 deletions(-) create mode 100644 tests/state.test.ts diff --git a/AGENTS.md b/AGENTS.md index 321ca24..459979d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Always run after making changes: bun run typecheck ``` -There is no build step during development. TypeScript source files are published directly and loaded natively by Bun. +There is no build step during local development. TypeScript source files are loaded natively by Bun; the published package is bundled by `prepack`. ## Testing @@ -29,7 +29,7 @@ released from the `1.x` line; do not add V1 compatibility shims to `main`. ```text src/ ├── index.ts — V2 plugin default export (id + setup) -├── plugin.ts — setup(): config, hooks, event subscription, dispatch +├── plugin.ts — setup(): config, model.request hook, event subscription, dispatch ├── state.ts — per-process shared OTel SDK + tracing state (globalThis) ├── types.ts — Shared types (OpenCodeEvent, HandlerContext, TracingState, etc.) ├── config.ts — Env/option config loading and log level resolution @@ -39,8 +39,8 @@ src/ ├── trace-context.ts — W3C trace-context inject/extract ├── util.ts — errorSummary, setBoundedMap, context resolution, attrs └── handlers/ - ├── session.ts — session.created, session.execution.*, session.status/idle, usage - ├── step.ts — session.step.* (LLM spans + token/cost/cache metrics) + ├── session.ts — session.created, session.inbox.enqueued, session.execution.*, retry/idle/usage + ├── step.ts — session.step.*, session.text.ended (LLM spans + token/cost/cache metrics) ├── tool.ts — session.tool.* (tool spans, duration, commit detection) ├── permission.ts — permission.asked/replied └── chat-headers.ts — session.hook("model.request") trace propagation @@ -56,6 +56,7 @@ src/ - **`setBoundedMap` / `markSeen`** — always use these for correlation maps (`toolMeta`, `stepMeta`, `pendingPrompts`, `pendingPermissions`, `seenEvents`) to prevent unbounded growth. - **Single source of truth for tokens/cost** — token and cost counters are incremented once per `session.step.ended`/`failed`; session totals come from `session.usage.updated` (cumulative) with a per-step fallback. - **Event de-duplication** — V2 may deliver the same event to multiple plugin instances; dedupe by `event.id` via `markSeen`. +- **Multi-location configuration** — process-wide exporters require identical telemetry configuration; reject a conflicting setup and derive project attributes from the observed session, not the loading location. - **Shutdown** — providers are flushed (never shut down) on plugin cleanup and once per process on `beforeExit`. Shutting down the global OTel providers poisons them for the rest of the process. - **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_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. - **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var (or the `enabled` plugin option). The plugin always loads regardless. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 037e289..a4e9cfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,16 +15,16 @@ bun install ## Development workflow -Point your local opencode config at the repo so changes are picked up immediately without a build step. In `~/.config/opencode/opencode.json`: +Install dependencies in the checkout with `bun install`. In the project where you run +OpenCode, create `.opencode/plugins/otel/index.ts`: -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugins": [{ "package": "/path/to/opencode-plugin-otel/src/index.ts" }] -} +```ts +export { default } from "/path/to/opencode-plugin-otel/src/index.ts" ``` -opencode loads TypeScript natively via Bun, so there is no build step required during development. +OpenCode V2 discovers the directory automatically and loads TypeScript via Bun, so +there is no build step during development. A `plugins` entry pointing directly to +an absolute `.ts` file is rejected by OpenCode `2.0.1`. > **Branching:** `main` targets OpenCode V2. The OpenCode V1 plugin is maintained on the `v1` branch > (branched from the last `1.x` tag) — open V1 bug/security fixes against `v1`, not `main`. diff --git a/README.md b/README.md index cd3e657..e31e81d 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,21 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet | `2.x` (`main`) | V2 (`>=2`) | `plugins` | default export `{ id, setup }` | | `1.x` (`v1` branch) | V1 | `plugin` | named `OtelPlugin` export | -OpenCode V2 replaced the coarse V1 event stream (`message.updated`, `message.part.updated`, -`permission.*`, `command.executed`, `session.diff`) with a granular taxonomy +OpenCode V2 replaced the coarse V1 message events (`message.updated`, `message.part.updated`) +and removed `command.executed` and `session.diff` from the plugin event stream. It provides +`permission.asked` / `permission.replied` in place of V1's `permission.updated` / `permission.replied`, +plus a granular session taxonomy (`session.execution.*`, `session.step.*`, `session.tool.*`, `session.usage.updated`, `session.retry.scheduled`). This plugin consumes the V2 stream directly, which produces more accurate LLM and tool span timings than the V1 implementation. -**Signals not available in V2:** the `lines_of_code.count` / `lines_of_code.total` metrics relied on the -V1 `session.diff` event and are not emitted (the V2 plugin context does not expose a session diff). -`command.executed`-based instrumentation is also gone; git commit detection is preserved by inspecting -shell tool input. Message/part spans are replaced by richer per-step LLM spans. +**V1 signal parity:** `lines_of_code.count` / `lines_of_code.total` relied on the V1 +`session.diff` event and are not emitted. The V2 client has a session-diff endpoint, +but the V2 plugin context does not expose it; the VCS diff API is repository-scoped +and is not an equivalent per-session total. `command.executed` instrumentation is +replaced by successful shell-tool completion. Message/part spans are replaced by +per-step LLM spans; completed text segments still populate `output.value` and +`llm.output_messages`. ## What it instruments @@ -59,18 +64,18 @@ shell tool input. Message/part spans are replaced by richer per-step LLM spans. | Metric | Type | Description | |--------|------|-------------| -| `opencode.session.count` | Counter | Incremented on each `session.created` event | +| `opencode.session.count` | Counter | Incremented on `session.created`, or lazily when a pre-existing session is first observed | | `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` (per `session.step.ended`) | | `opencode.cost.usage` | Counter | USD cost per completed LLM step | -| `opencode.commit.count` | Counter | Git commits detected via shell tool input | +| `opencode.commit.count` | Counter | `git commit` commands observed in successful, executed shell-tool calls; Git's resulting repository state is not verified | | `opencode.tool.duration` | Histogram | Tool execution time in milliseconds | | `opencode.cache.count` | Counter | Cache activity per step: `type=cacheRead` or `type=cacheCreation` | -| `opencode.session.duration` | Histogram | Session duration from created to idle in milliseconds | -| `opencode.message.count` | Counter | Completed assistant messages per session | +| `opencode.session.duration` | Histogram | Observed duration from session creation (or first observation) to execution end / idle in milliseconds | +| `opencode.message.count` | Counter | Assistant messages with a completed or failed LLM step | | `opencode.session.token.total` | Histogram | Total tokens consumed per session, recorded when an execution ends | | `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded when an execution ends | | `opencode.model.usage` | Counter | Messages per model and provider | -| `opencode.retry.count` | Counter | API retries observed via `session.retry.scheduled` / `session.status` | +| `opencode.retry.count` | Counter | API retries observed via durable `session.retry.scheduled` events | | `opencode.subtask.count` | Counter | Sub-agent sessions observed via `session.created` with a `parentID` | ### Log events @@ -80,12 +85,13 @@ shell tool input. Message/part spans are replaced by richer per-step LLM spans. | `session.created` | Session started | | `session.idle` | Session went idle (includes total tokens, cost, messages) | | `session.error` | Execution failed | -| `user_prompt` | User sent a message (includes `prompt_length`, `delivery`; also `prompt` when `OPENCODE_CAPTURE_PROMPT_IN_LOGS` is set) | +| `user_prompt` | User prompt durably admitted via `session.inbox.enqueued` (includes `prompt_length`, `delivery`; also `prompt` when `OPENCODE_CAPTURE_PROMPT_IN_LOGS` is set) | | `api_request` | Completed LLM step (tokens, cost) | | `api_error` | Failed LLM step (error summary) | | `tool_result` | Tool completed or errored (duration, success, output size) | | `tool_decision` | Permission prompt answered (accept/reject) | | `commit` | Git commit detected | +| `subtask_invoked` | Sub-agent session created (agent and parent session ID) | ## Installation @@ -98,19 +104,27 @@ Add the plugin to your opencode config at `~/.config/opencode/opencode.json`: } ``` -Or point directly at a local checkout for development: +For local development, install dependencies with `bun install` in the checkout and +create `.opencode/plugins/otel/index.ts` in the project where OpenCode runs: -```json -{ - "$schema": "https://opencode.ai/config.json", - "plugins": [{ "package": "/path/to/opencode-plugin-otel/src/index.ts" }] -} +```ts +export { default } from "/path/to/opencode-plugin-otel/src/index.ts" ``` +OpenCode V2 discovers this directory automatically. On OpenCode `2.0.1`, a +`plugins` package entry pointing directly to an absolute `.ts` file is rejected; +the package loader expects a directory. + ## Configuration The plugin reads its settings from `OPENCODE_*` environment variables and/or from inline [plugin options](#plugin-options-opencodejson) in `opencode.json`. When both are present, an option wins over the matching environment variable, which wins over the built-in default. +When OpenCode loads multiple locations in one process, all enabled instances must +use identical telemetry configuration. A conflicting endpoint, authentication, +resource attribute, metric prefix, or signal option fails setup rather than +exporting one location's data using another location's settings. Event attributes +use the observed session's project ID when available. + The environment variables (set them in your shell profile — `~/.zshrc`, `~/.bashrc`, etc.): | Variable | Default | Description | @@ -123,7 +137,7 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `OPENCODE_METRIC_PREFIX` | `opencode.` | Prefix for all metric names (e.g. set to `claude_code.` for Claude Code dashboard compatibility) | | `OPENCODE_DISABLE_METRICS` | *(unset)* | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) | | `OPENCODE_DISABLE_LOGS` | *(unset)* | Set to any non-empty value to suppress all OTLP log events while leaving metrics and traces unchanged | -| `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | *(unset)* | Set to any non-empty value to include the full prompt text in the `prompt` attribute of `user_prompt` log events. **Log events only** — trace spans always carry the prompt in `input.value` regardless of this flag (disable span-level capture separately via `OPENCODE_DISABLE_TRACES`). **Off by default — prompts may contain secrets or PII; enable only for trusted collectors.** | +| `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | *(unset)* | Set to any non-empty value to include the full prompt text in the `prompt` attribute of `user_prompt` log events. **Log events only** — trace spans carry an observed prompt in `input.value` regardless of this flag (disable span-level capture separately via `OPENCODE_DISABLE_TRACES`). **Off by default — prompts may contain secrets or PII; enable only for trusted collectors.** | | `OPENCODE_DISABLE_TRACES` | *(unset)* | Comma-separated list of trace types to disable (`session`, `llm`, `tool`). Use `all`, `*`, `true`, or `1` to disable every trace type | | `OPENCODE_OTLP_HEADERS` | *(unset)* | Comma-separated `key=value` headers added to all OTLP exports. **Keep out of version control — may contain sensitive auth tokens.** | | `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`. | @@ -247,6 +261,8 @@ If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static Use `OPENCODE_TRACE_PROPAGATION_PROVIDERS` to connect this plugin's LLM spans to spans emitted by an LLM gateway such as LiteLLM or vLLM. For matching provider IDs, the plugin injects the current `opencode.llm` span as the W3C `traceparent` header and includes `tracestate` when present. +Propagation is limited to primary agent-loop requests whose provider, model, and agent match the active LLM step; title, compaction, and transient generation requests are not parented to that step. + ```bash export OPENCODE_TRACE_PROPAGATION_PROVIDERS="company-litellm,vllm" ``` diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index 0f0bb65..8c76f19 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -12,9 +12,10 @@ export type ModelRequestEvent = { /** Injects the active LLM span's W3C trace context into outbound model requests. */ export function handleModelRequest(event: ModelRequestEvent, ctx: HandlerContext): void { + if (event.kind !== "primary") return const providerID = event.model.providerID if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return const active = ctx.tracing.activeLlm.get(event.sessionID) - if (!active || active.providerID !== providerID) return + if (!active || active.providerID !== providerID || active.modelID !== event.model.id || active.agent !== event.agent) return injectTraceContext(active.spanContext, event.headers) } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 33dbc58..ab93d32 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -16,6 +16,7 @@ import { errorSummary, isMetricEnabled, isTraceEnabled, + markSeen, modelRef, resolveRunContext, setBoundedMap, @@ -24,9 +25,38 @@ import { const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND +export function handlePromptEnqueued(e: EventOf<"session.inbox.enqueued">, ctx: HandlerContext, capturePrompt: boolean) { + const { sessionID, inboxID, item } = e.data + if (item.type !== "user") return + const parts = [item.payload.text] + for (const file of item.payload.files ?? []) parts.push(file.name ?? (file.source.type === "uri" ? file.source.uri : "")) + for (const agent of item.payload.agents ?? []) parts.push(agent.name) + for (const skill of item.payload.skills ?? []) parts.push(skill.name) + const text = parts.filter(Boolean).join("\n") + setBoundedMap(ctx.tracing.pendingPrompts, sessionID, { text, startMs: e.created }) + const totals = ctx.tracing.sessionTotals.get(sessionID) + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "user_prompt", + attributes: { + "event.name": "user_prompt", + "session.id": sessionID, + "message.id": inboxID, + ...(totals ? agentAttrs(totals.agent, totals.agentType) : {}), + prompt_length: text.length, + ...(capturePrompt ? { prompt: text } : {}), + delivery: item.delivery, + ...ctx.commonAttrs, + }, + }) +} + function countSession(sessionID: string, isSubagent: boolean, ctx: HandlerContext) { if (ctx.tracing.countedSessions.has(sessionID)) return - ctx.tracing.countedSessions.add(sessionID) + markSeen(ctx.tracing.countedSessions, sessionID) if (isMetricEnabled("session.count", ctx)) { ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, @@ -65,6 +95,7 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler const agentType: SessionAgentType = isSubagent ? "subagent" : "primary" const agent = d.agent ?? "unknown" + setBoundedMap(ctx.tracing.sessionProjects, d.sessionID, d.projectID) countSession(d.sessionID, isSubagent, ctx) if (isSubagent && isMetricEnabled("subtask.count", ctx)) { ctx.instruments.subtaskCounter.add(1, { @@ -73,6 +104,22 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler "agent.type": "subagent", }) } + if (isSubagent) { + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: e.created, + observedTimestamp: Date.now(), + body: "subtask_invoked", + attributes: { + "event.name": "subtask_invoked", + "session.id": d.sessionID, + "parent.session.id": d.parentID, + ...agentAttrs(agent, agentType), + ...ctx.commonAttrs, + }, + }) + } setBoundedMap(ctx.tracing.sessionTotals, d.sessionID, { startMs: e.created, @@ -208,19 +255,22 @@ export function handleExecutionEnded( } } -/** Handles `session.status`: counts retries and finalizes the session on idle. */ +/** Handles `session.status` idle and retry diagnostics. */ export function handleSessionStatus(e: EventOf<"session.status">, ctx: HandlerContext) { const { sessionID, status } = e.data if (status.type === "retry") { - if (isMetricEnabled("retry.count", ctx)) { - ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) - } void ctx.log("debug", "otel: retry scheduled", { sessionID, attempt: status.attempt }) return } if (status.type === "idle") finalizeSession(sessionID, ctx) } +export function handleRetryScheduled(e: EventOf<"session.retry.scheduled">, ctx: HandlerContext) { + if (isMetricEnabled("retry.count", ctx)) { + ctx.instruments.retryCounter.add(1, { ...ctx.commonAttrs, "session.id": e.data.sessionID }) + } +} + /** Handles the deprecated `session.idle` event as an idle finalization signal. */ export function handleSessionIdle(e: EventOf<"session.idle">, ctx: HandlerContext) { finalizeSession(e.data.sessionID, ctx) @@ -314,6 +364,7 @@ function sweepExecution(sessionID: string, ctx: HandlerContext) { ctx.tracing.stepSpans.delete(messageID) ctx.tracing.stepSpanContexts.delete(messageID) ctx.tracing.stepMeta.delete(messageID) + ctx.tracing.stepOutputs.delete(messageID) } for (const [id, perm] of ctx.tracing.pendingPermissions) { if (perm.sessionID === sessionID) ctx.tracing.pendingPermissions.delete(id) diff --git a/src/handlers/step.ts b/src/handlers/step.ts index 66d3d1e..1525f17 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -18,6 +18,8 @@ import { LLM_TOKEN_COUNT_TOTAL, MimeType, OpenInferenceSpanKind, + OUTPUT_MIME_TYPE, + OUTPUT_VALUE, SemanticConventions, SESSION_ID, } from "@arizeai/openinference-semantic-conventions" @@ -29,6 +31,7 @@ import { genAiProviderName, isMetricEnabled, isTraceEnabled, + markSeen, resolveRunContext, setBoundedMap, } from "../util.ts" @@ -118,6 +121,31 @@ export function handleStepStarted(e: EventOf<"session.step.started">, ctx: Handl }) } +export function handleTextEnded(e: EventOf<"session.text.ended">, ctx: HandlerContext) { + const { assistantMessageID, ordinal, text } = e.data + const output = ctx.tracing.stepOutputs.get(assistantMessageID) ?? new Map() + if (output.size < 32 || output.has(ordinal)) output.set(ordinal, text.slice(0, 64_000)) + setBoundedMap(ctx.tracing.stepOutputs, assistantMessageID, output) +} + +function countMessage(messageID: string, sessionID: string, modelID: string, providerID: string, agent: string, ctx: HandlerContext): boolean { + if (ctx.tracing.countedMessages.has(messageID)) return false + markSeen(ctx.tracing.countedMessages, messageID) + if (isMetricEnabled("message.count", ctx)) { + ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) + } + if (isMetricEnabled("model.usage", ctx)) { + ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID, agent }) + } + return true +} + +function outputText(messageID: string, ctx: HandlerContext): string | undefined { + const output = ctx.tracing.stepOutputs.get(messageID) + if (!output) return undefined + return [...output].sort(([a], [b]) => a - b).map(([, text]) => text).join("\n").slice(0, 64_000) +} + /** Records token/cost/cache metrics for a completed step and emits an `api_request` log. */ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerContext) { const d = e.data @@ -129,26 +157,12 @@ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerCo const providerID = meta?.providerID ?? "unknown" recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost, ctx) - const firstStep = !ctx.tracing.countedMessages.has(d.assistantMessageID) - if (firstStep) { - ctx.tracing.countedMessages.add(d.assistantMessageID) - if (isMetricEnabled("message.count", ctx)) { - ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": d.sessionID, model: modelID, agent }) - } - if (isMetricEnabled("model.usage", ctx)) { - ctx.instruments.modelUsageCounter.add(1, { - ...ctx.commonAttrs, - "session.id": d.sessionID, - model: modelID, - provider: providerID, - agent, - }) - } - } + const firstStep = countMessage(d.assistantMessageID, d.sessionID, modelID, providerID, agent, ctx) accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost, firstStep, ctx) const span = ctx.tracing.stepSpans.get(d.assistantMessageID) if (span) { + const output = outputText(d.assistantMessageID, ctx) span.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType, @@ -161,6 +175,11 @@ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerCo [LLM_FINISH_REASON]: d.finish, [LLM_COST_TOTAL]: d.cost, cost_usd: d.cost, + ...(output ? { + [OUTPUT_VALUE]: output, + [OUTPUT_MIME_TYPE]: MimeType.TEXT, + [LLM_OUTPUT_MESSAGES]: JSON.stringify([{ role: "assistant", content: output }]), + } : {}), }) span.setStatus({ code: SpanStatusCode.OK }) span.end(e.created) @@ -202,17 +221,19 @@ export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: Handler const providerID = meta?.providerID ?? "unknown" const error = errorSummary(d.error) - if (d.tokens) recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost ?? 0, ctx) - if (!ctx.tracing.countedMessages.has(d.assistantMessageID)) { - ctx.tracing.countedMessages.add(d.assistantMessageID) - accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost ?? 0, true, ctx) - } else { - accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost ?? 0, false, ctx) - } + recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost, ctx) + const firstStep = countMessage(d.assistantMessageID, d.sessionID, modelID, providerID, agent, ctx) + accumulateTotals(d.sessionID, allTokens(d.tokens), d.cost ?? 0, firstStep, ctx) const span = ctx.tracing.stepSpans.get(d.assistantMessageID) if (span) { - span.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType, [LLM_FINISH_REASON]: d.finish ?? "error" }) + const output = outputText(d.assistantMessageID, ctx) + span.setAttributes({ + [AGENT_NAME]: agent, + "agent.type": agentType, + [LLM_FINISH_REASON]: d.finish ?? "error", + ...(output ? { [OUTPUT_VALUE]: output, [OUTPUT_MIME_TYPE]: MimeType.TEXT } : {}), + }) span.setStatus({ code: SpanStatusCode.ERROR, message: error }) span.end(e.created) } @@ -242,12 +263,11 @@ function recordUsageMetrics( modelID: string, agent: string, tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } | undefined, - cost: number, + cost: number | undefined, ctx: HandlerContext, ) { - if (!tokens) return const base = { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent } - if (isMetricEnabled("token.usage", ctx)) { + if (tokens && isMetricEnabled("token.usage", ctx)) { const { tokenCounter } = ctx.instruments tokenCounter.add(tokens.input, { ...base, type: "input" }) tokenCounter.add(tokens.output, { ...base, type: "output" }) @@ -255,10 +275,10 @@ function recordUsageMetrics( tokenCounter.add(tokens.cache.read, { ...base, type: "cacheRead" }) tokenCounter.add(tokens.cache.write, { ...base, type: "cacheCreation" }) } - if (isMetricEnabled("cost.usage", ctx)) { + if (cost !== undefined && isMetricEnabled("cost.usage", ctx)) { ctx.instruments.costCounter.add(cost, base) } - if (isMetricEnabled("cache.count", ctx)) { + if (tokens && isMetricEnabled("cache.count", ctx)) { if (tokens.cache.read > 0) ctx.instruments.cacheCounter.add(1, { ...base, type: "cacheRead" }) if (tokens.cache.write > 0) ctx.instruments.cacheCounter.add(1, { ...base, type: "cacheCreation" }) } @@ -276,4 +296,5 @@ function cleanupStep(assistantMessageID: string, sessionID: string, ctx: Handler ctx.tracing.stepSpanContexts.delete(assistantMessageID) ctx.tracing.stepMeta.delete(assistantMessageID) ctx.tracing.activeLlm.delete(sessionID) + ctx.tracing.stepOutputs.delete(assistantMessageID) } diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts index 2827b5e..ce0289e 100644 --- a/src/handlers/tool.ts +++ b/src/handlers/tool.ts @@ -63,11 +63,10 @@ export function handleToolInputStarted(e: EventOf<"session.tool.input.started">, setBoundedMap(ctx.tracing.toolSpanContexts, d.id, span.spanContext()) } -/** Attaches the tool input to the span and detects `git commit` invocations in shell tools. */ +/** Attaches tool input to the span and retains the command for terminal commit detection. */ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: HandlerContext) { const d = e.data const meta = ctx.tracing.toolMeta.get(d.id) - const tool = meta?.tool ?? "unknown" const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) const inputJson = safeJson(d.input) @@ -82,39 +81,21 @@ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: Handler }) } - if (SHELL_TOOL_RE.test(tool)) { - const command = typeof d.input["command"] === "string" ? d.input["command"] : inputJson - if (GIT_COMMIT_RE.test(command)) { - if (isMetricEnabled("commit.count", ctx)) { - ctx.instruments.commitCounter.add(1, { ...ctx.commonAttrs, "session.id": d.sessionID }) - } - ctx.emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: e.created, - observedTimestamp: Date.now(), - body: "commit", - attributes: { - "event.name": "commit", - "session.id": d.sessionID, - ...agentAttrs(agentName, agentType), - ...ctx.commonAttrs, - }, - }) - } + if (meta && typeof d.input["command"] === "string") { + setBoundedMap(ctx.tracing.toolMeta, d.id, { ...meta, command: d.input["command"] }) } } /** Ends a successful tool call: records duration, sets output attributes, and emits `tool_result`. */ export function handleToolSuccess(e: EventOf<"session.tool.success">, ctx: HandlerContext) { const output = contentText(e.data.content) - finishTool(e.data.id, e.data.sessionID, e.created, true, output, undefined, ctx) + finishTool(e.data.id, e.data.sessionID, e.created, true, e.data.executed, output, undefined, ctx) } /** Ends a failed tool call: records duration, sets error attributes, and emits `tool_result`. */ export function handleToolFailed(e: EventOf<"session.tool.failed">, ctx: HandlerContext) { const output = contentText(e.data.content) - finishTool(e.data.id, e.data.sessionID, e.created, false, output, errorSummary(e.data.error), ctx) + finishTool(e.data.id, e.data.sessionID, e.created, false, e.data.executed, output, errorSummary(e.data.error), ctx) } function finishTool( @@ -122,6 +103,7 @@ function finishTool( sessionID: string, endMs: number, success: boolean, + executed: boolean, output: string, error: string | undefined, ctx: HandlerContext, @@ -134,6 +116,25 @@ function finishTool( const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) const sizeBytes = output ? Buffer.byteLength(output, "utf8") : 0 + if (success && executed && meta?.command && SHELL_TOOL_RE.test(tool) && GIT_COMMIT_RE.test(meta.command)) { + if (isMetricEnabled("commit.count", ctx)) { + ctx.instruments.commitCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID }) + } + ctx.emitLog({ + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + timestamp: endMs, + observedTimestamp: Date.now(), + body: "commit", + attributes: { + "event.name": "commit", + "session.id": sessionID, + ...agentAttrs(agentName, agentType), + ...ctx.commonAttrs, + }, + }) + } + if (isMetricEnabled("tool.duration", ctx)) { ctx.instruments.toolDurationHistogram.record(durationMs, { ...ctx.commonAttrs, diff --git a/src/otel.ts b/src/otel.ts index 6832b7e..64c9d59 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -170,7 +170,7 @@ export function createInstruments(prefix: string): Instruments { }), cacheCounter: meter.createCounter(`${prefix}cache.count`, { unit: "{request}", - description: "Token cache activity (cacheRead/cacheCreation) per completed assistant message", + description: "Token cache activity (cacheRead/cacheCreation) per completed LLM step", }), sessionDurationHistogram: meter.createHistogram(`${prefix}session.duration`, { unit: "ms", @@ -197,11 +197,11 @@ export function createInstruments(prefix: string): Instruments { }), retryCounter: meter.createCounter(`${prefix}retry.count`, { unit: "{retry}", - description: "Number of API retries observed via session.status events", + description: "Number of API retries observed via session.retry.scheduled events", }), subtaskCounter: meter.createCounter(`${prefix}subtask.count`, { unit: "{subtask}", - description: "Number of sub-agent invocations observed via subtask message parts", + description: "Number of sub-agent sessions observed via session.created with a parentID", }), } } diff --git a/src/plugin.ts b/src/plugin.ts index e6f881c..15fb655 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,4 @@ import { ROOT_CONTEXT } from "@opentelemetry/api" -import { SeverityNumber } from "@opentelemetry/api-logs" import pkg from "../package.json" with { type: "json" } import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, type OtelPluginOptions } from "./config.ts" import { probeEndpoint } from "./probe.ts" @@ -7,16 +6,19 @@ import { forceFlushOtel } from "./otel.ts" import { remoteParentContext } from "./trace-context.ts" import { acquireSharedOtel, acquireTracingState, flushSharedOtel, releaseSharedOtel } from "./state.ts" import { LEVELS, type HandlerContext, type Level, type OpenCodeContext, type OpenCodeEvent } from "./types.ts" -import { agentAttrs, markSeen, setBoundedMap } from "./util.ts" +import { contextForSession, markSeen, setBoundedMap } from "./util.ts" import { + finalizeSession, handleExecutionEnded, handleExecutionStarted, + handlePromptEnqueued, handleSessionCreated, handleSessionIdle, + handleRetryScheduled, handleSessionStatus, handleUsageUpdated, } from "./handlers/session.ts" -import { handleStepEnded, handleStepFailed, handleStepStarted } from "./handlers/step.ts" +import { handleStepEnded, handleStepFailed, handleStepStarted, handleTextEnded } from "./handlers/step.ts" import { handleToolCalled, handleToolFailed, handleToolInputStarted, handleToolSuccess } from "./handlers/tool.ts" import { handlePermissionAsked, handlePermissionReplied } from "./handlers/permission.ts" import { handleModelRequest } from "./handlers/chat-headers.ts" @@ -96,10 +98,7 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> log, emitLog, instruments: shared.instruments, - commonAttrs: { - ...parseAttributePairs(config.spanAttributes), - "project.id": ctx.location.project.id, - } as const, + commonAttrs: parseAttributePairs(config.spanAttributes), disabledMetrics: config.disabledMetrics, disabledTraces: config.disabledTraces, tracer: shared.tracer, @@ -116,93 +115,91 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> await log("info", "prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events") } - await ctx.session.hook("prompt", (event) => { - const prompt = event.prompt - const parts = [prompt.text] - for (const file of prompt.files ?? []) parts.push(file.name ?? file.uri) - for (const agent of prompt.agents ?? []) parts.push(agent.name) - for (const skill of prompt.skills ?? []) parts.push(skill.id) - const text = parts.filter(Boolean).join("\n") - setBoundedMap(tracing.pendingPrompts, event.sessionID, { text, startMs: Date.now() }) - const totals = tracing.sessionTotals.get(event.sessionID) - emitLog({ - severityNumber: SeverityNumber.INFO, - severityText: "INFO", - timestamp: Date.now(), - observedTimestamp: Date.now(), - body: "user_prompt", - attributes: { - "event.name": "user_prompt", - "session.id": event.sessionID, - ...(totals ? agentAttrs(totals.agent, totals.agentType) : {}), - prompt_length: text.length, - ...(config.capturePromptInLogs ? { prompt: text } : {}), - delivery: event.delivery, - ...hctx.commonAttrs, - }, - }) - }) + const scoped = (sessionID: string) => contextForSession( + sessionID, + hctx, + async (id) => (await ctx.session.get({ sessionID: id })).projectID, + ) await ctx.session.hook("model.request", (event) => { handleModelRequest(event, hctx) }) const dispatch = async (event: OpenCodeEvent): Promise => { + if (event.type === "session.created") { + setBoundedMap(tracing.sessionProjects, event.data.sessionID, event.data.projectID) + } + const sessionID = "sessionID" in event.data && typeof event.data.sessionID === "string" + ? event.data.sessionID + : undefined + const eventCtx = sessionID ? await scoped(sessionID) : hctx switch (event.type) { case "session.created": - handleSessionCreated(event, hctx) + handleSessionCreated(event, eventCtx) + break + case "session.inbox.enqueued": + handlePromptEnqueued(event, eventCtx, config.capturePromptInLogs) break case "session.execution.started": - handleExecutionStarted(event, hctx) + handleExecutionStarted(event, eventCtx) break case "session.execution.succeeded": - handleExecutionEnded(event, hctx, { type: "succeeded" }) + handleExecutionEnded(event, eventCtx, { type: "succeeded" }) + finalizeSession(event.data.sessionID, eventCtx) await forceFlushOtel(shared.providers) break case "session.execution.failed": - handleExecutionEnded(event, hctx, { type: "failed", error: event.data.error }) + handleExecutionEnded(event, eventCtx, { type: "failed", error: event.data.error }) + finalizeSession(event.data.sessionID, eventCtx) await forceFlushOtel(shared.providers) break case "session.execution.interrupted": - handleExecutionEnded(event, hctx, { type: "interrupted", reason: event.data.reason }) + handleExecutionEnded(event, eventCtx, { type: "interrupted", reason: event.data.reason }) + finalizeSession(event.data.sessionID, eventCtx) await forceFlushOtel(shared.providers) break case "session.status": - handleSessionStatus(event, hctx) + handleSessionStatus(event, eventCtx) break case "session.idle": - handleSessionIdle(event, hctx) + handleSessionIdle(event, eventCtx) await forceFlushOtel(shared.providers) break case "session.usage.updated": - handleUsageUpdated(event, hctx) + handleUsageUpdated(event, eventCtx) + break + case "session.retry.scheduled": + handleRetryScheduled(event, eventCtx) break case "session.step.started": - handleStepStarted(event, hctx) + handleStepStarted(event, eventCtx) + break + case "session.text.ended": + handleTextEnded(event, eventCtx) break case "session.step.ended": - handleStepEnded(event, hctx) + handleStepEnded(event, eventCtx) break case "session.step.failed": - handleStepFailed(event, hctx) + handleStepFailed(event, eventCtx) break case "session.tool.input.started": - handleToolInputStarted(event, hctx) + handleToolInputStarted(event, eventCtx) break case "session.tool.called": - handleToolCalled(event, hctx) + handleToolCalled(event, eventCtx) break case "session.tool.success": - handleToolSuccess(event, hctx) + handleToolSuccess(event, eventCtx) break case "session.tool.failed": - handleToolFailed(event, hctx) + handleToolFailed(event, eventCtx) break case "permission.asked": - handlePermissionAsked(event, hctx) + handlePermissionAsked(event, eventCtx) break case "permission.replied": - handlePermissionReplied(event, hctx) + handlePermissionReplied(event, eventCtx) break default: break @@ -215,7 +212,14 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> for await (const event of ctx.event.subscribe({ signal: controller.signal })) { if (tracing.seenEvents.has(event.id)) continue markSeen(tracing.seenEvents, event.id) - await dispatch(event) + try { + await dispatch(event) + } catch (err) { + await log("error", "otel: failed to handle event", { + type: event.type, + error: err instanceof Error ? err.message : String(err), + }) + } } } catch (err) { if (!controller.signal.aborted) { diff --git a/src/state.ts b/src/state.ts index fd978fb..9888f75 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,6 @@ import { trace } from "@opentelemetry/api" import { logs } from "@opentelemetry/api-logs" +import { createHash } from "node:crypto" import { createInstruments, forceFlushOtel, setupOtel } from "./otel.ts" import type { PluginConfig } from "./config.ts" import type { SharedOtel, TracingState } from "./types.ts" @@ -14,6 +15,17 @@ function globals(): Globals { return globalThis as unknown as Globals } +export function configKey(config: PluginConfig): string { + const normalized = JSON.stringify({ + ...config, + disabledMetrics: [...config.disabledMetrics].sort(), + disabledTraces: [...config.disabledTraces].sort(), + tracePropagationProviders: [...config.tracePropagationProviders].sort(), + logLevel: undefined, + }) + return createHash("sha256").update(normalized).digest("hex") +} + /** * Returns the process-wide shared OTel SDK instance, creating it on first use. * OpenCode may load one plugin instance per location, but `setGlobalMeterProvider` @@ -22,8 +34,10 @@ function globals(): Globals { */ export async function acquireSharedOtel(config: PluginConfig, version: string): Promise { const g = globals() + const key = configKey(config) const existing = g[OTEL_KEY] as SharedOtel | undefined if (existing) { + if (existing.configKey !== key) throw new Error("OpenCode OTel plugin instances must use identical telemetry configuration within one process") existing.refs += 1 return existing } @@ -45,14 +59,15 @@ export async function acquireSharedOtel(config: PluginConfig, version: string): logger: logs.getLogger("com.opencode"), tracer: trace.getTracer("com.opencode"), refs: 0, + configKey: key, } g[OTEL_KEY] = shared - g[OTEL_PENDING_KEY] = undefined return shared - })() + })().finally(() => { g[OTEL_PENDING_KEY] = undefined }) } const shared = await (g[OTEL_PENDING_KEY] as Promise) + if (shared.configKey !== key) throw new Error("OpenCode OTel plugin instances must use identical telemetry configuration within one process") shared.refs += 1 return shared } @@ -93,6 +108,8 @@ export function acquireTracingState(): TracingState { sessionTotals: new Map(), countedSessions: new Set(), countedMessages: new Set(), + sessionProjects: new Map(), + stepOutputs: new Map(), pendingPrompts: new Map(), pendingPermissions: new Map(), activeLlm: new Map(), diff --git a/src/types.ts b/src/types.ts index 219e0fc..c772942 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,7 @@ export type ToolMeta = { assistantMessageID: string tool: string startMs: number + command?: string } /** Live LLM request metadata used by the `model.request` trace-propagation hook. */ @@ -116,6 +117,7 @@ export type SharedOtel = { logger: Logger tracer: Tracer refs: number + configKey: string } /** @@ -136,6 +138,8 @@ export type TracingState = { sessionTotals: Map countedSessions: Set countedMessages: Set + sessionProjects: Map + stepOutputs: Map> pendingPrompts: Map pendingPermissions: Map activeLlm: Map diff --git a/src/util.ts b/src/util.ts index e8590f3..9eda812 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,5 @@ import { trace, type Context } from "@opentelemetry/api" -import { MAX_PENDING, type HandlerContext, type SessionAgentType, type TracingState } from "./types.ts" +import { MAX_PENDING, MAX_SEEN_EVENTS, type HandlerContext, type SessionAgentType, type TracingState } from "./types.ts" const GEN_AI_PROVIDER_NAMES: Readonly> = { "amazon-bedrock": "aws.bedrock", @@ -62,7 +62,7 @@ export function setBoundedMap(map: Map, key: K, value: V) { /** Records an event id for de-duplication, evicting the oldest entry when at capacity. */ export function markSeen(seen: TracingState["seenEvents"], id: string): void { - if (!seen.has(id) && seen.size >= MAX_PENDING * 20) { + if (!seen.has(id) && seen.size >= MAX_SEEN_EVENTS) { const [first] = seen.values() if (first !== undefined) seen.delete(first) } @@ -123,3 +123,20 @@ export function getSessionAgentMeta( agentType: totals?.agentType ?? "unknown", } } + +export async function contextForSession( + sessionID: string, + ctx: HandlerContext, + projectForSession: (sessionID: string) => Promise, +): Promise { + let projectID = ctx.tracing.sessionProjects.get(sessionID) + if (!projectID) { + try { + projectID = await projectForSession(sessionID) + setBoundedMap(ctx.tracing.sessionProjects, sessionID, projectID) + } catch { + return ctx + } + } + return { ...ctx, commonAttrs: { ...ctx.commonAttrs, "project.id": projectID } } +} diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 3da268d..df76969 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -34,4 +34,13 @@ describe("handleModelRequest", () => { handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) expect(headers["traceparent"]).toBeDefined() }) + + test("does not propagate a primary step into an auxiliary model request", () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("*") + ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "vllm", spanContext: SPAN_CONTEXT }) + const headers: Record = {} + handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "title", headers }, ctx) + expect(headers["traceparent"]).toBeUndefined() + }) }) diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index 158e089..782eeb5 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -4,9 +4,11 @@ import { finalizeSession, handleExecutionEnded, handleExecutionStarted, + handlePromptEnqueued, handleSessionCreated, handleSessionIdle, handleSessionStatus, + handleRetryScheduled, handleUsageUpdated, } from "../../src/handlers/session.ts" import { makeCtx, evt, tokens } from "../helpers.ts" @@ -30,6 +32,30 @@ describe("handleSessionCreated", () => { expect(counters.subtask.calls).toHaveLength(1) expect(ctx.tracing.sessionTotals.get("sub_1")!.parentID).toBe("ses_1") expect(logger.records[0]!.attributes?.["agent.name"]).toBe("explore") + expect(logger.records.some((record) => record.body === "subtask_invoked")).toBe(true) + }) +}) + +describe("handlePromptEnqueued", () => { + test("logs admitted user prompts and stores their text for spans", () => { + const { ctx, logger } = makeCtx() + handlePromptEnqueued(evt("session.inbox.enqueued", { + sessionID: "ses_1", + inboxID: "msg_1", + item: { type: "user", payload: { text: "hello", files: [], agents: [], skills: [] }, delivery: "queue" }, + }), ctx, false) + expect(logger.records[0]!.body).toBe("user_prompt") + expect(logger.records[0]!.attributes?.["delivery"]).toBe("queue") + expect(logger.records[0]!.attributes?.["prompt"]).toBeUndefined() + expect(ctx.tracing.pendingPrompts.get("ses_1")!.text).toBe("hello") + }) + + test("skips non-user inbox items", () => { + const { ctx, logger } = makeCtx() + handlePromptEnqueued(evt("session.inbox.enqueued", { + sessionID: "ses_1", inboxID: "msg_2", item: { type: "synthetic", payload: { text: "hidden" }, delivery: "steer" }, + }), ctx, true) + expect(logger.records).toHaveLength(0) }) }) @@ -95,9 +121,11 @@ describe("handleExecutionEnded", () => { }) describe("handleSessionStatus", () => { - test("counts retries", () => { + test("counts durable retry events once rather than retry status", () => { const { ctx, counters } = makeCtx() handleSessionStatus(evt("session.status", { sessionID: "ses_1", status: { type: "retry", attempt: 1, message: "m", next: 2 } }), ctx) + expect(counters.retry.calls).toHaveLength(0) + handleRetryScheduled(evt("session.retry.scheduled", { sessionID: "ses_1", assistantMessageID: "m", attempt: 1, at: 2, error: { type: "x", message: "y" } }), ctx) expect(counters.retry.calls).toHaveLength(1) }) diff --git a/tests/handlers/step.test.ts b/tests/handlers/step.test.ts index 9b9ba01..e506e34 100644 --- a/tests/handlers/step.test.ts +++ b/tests/handlers/step.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test" import { SpanStatusCode } from "@opentelemetry/api" -import { handleStepEnded, handleStepFailed, handleStepStarted } from "../../src/handlers/step.ts" +import { handleStepEnded, handleStepFailed, handleStepStarted, handleTextEnded } from "../../src/handlers/step.ts" import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" import { makeCtx, evt, tokens } from "../helpers.ts" @@ -38,6 +38,8 @@ describe("handleStepEnded", () => { const { ctx, tracer, counters, logger } = makeCtx() seedSession(ctx) handleStepStarted(stepStarted(), ctx) + handleTextEnded(evt("session.text.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 1, text: "world" }), ctx) + handleTextEnded(evt("session.text.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, text: "hello" }), ctx) handleStepEnded( evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "stop", cost: 0.25, tokens: tokens(10, 5, 2, 3, 0) }, 2000), ctx, @@ -53,6 +55,8 @@ describe("handleStepEnded", () => { expect(llm.ended).toBe(true) expect(llm.status.code).toBe(SpanStatusCode.OK) expect(llm.attributes["llm.token_count.total"]).toBe(20) + expect(llm.attributes["output.value"]).toBe("hello\nworld") + expect(llm.attributes["llm.output_messages"]).toBe('[{"role":"assistant","content":"hello\\nworld"}]') }) test("counts a message once across multiple steps", () => { @@ -80,5 +84,13 @@ describe("handleStepFailed", () => { expect(llm.status.code).toBe(SpanStatusCode.ERROR) expect(logger.records.at(-1)!.body).toBe("api_error") expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ProviderError: boom") + expect(counters.message.calls).toHaveLength(1) + expect(counters.modelUsage.calls).toHaveLength(1) + }) + + test("records a failed step cost even when token usage is absent", () => { + const { ctx, counters } = makeCtx() + handleStepFailed(evt("session.step.failed", { sessionID: "s", assistantMessageID: "m", error: { type: "x", message: "y" }, cost: 2 }), ctx) + expect(counters.cost.calls.map((call) => call.value)).toEqual([2]) }) }) diff --git a/tests/handlers/tool.test.ts b/tests/handlers/tool.test.ts index 89d0de0..7fa8dd8 100644 --- a/tests/handlers/tool.test.ts +++ b/tests/handlers/tool.test.ts @@ -30,8 +30,10 @@ describe("handleToolCalled", () => { seed(ctx) handleToolInputStarted(inputStarted(), ctx) handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: { command: "git commit -m 'x'" }, executed: true }), ctx) + expect(counters.commit.calls).toHaveLength(0) + handleToolSuccess(evt("session.tool.success", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", content: [{ type: "text", text: "commit made" }], executed: true }), ctx) expect(counters.commit.calls).toHaveLength(1) - expect(logger.records.at(-1)!.body).toBe("commit") + expect(logger.records.some((record) => record.body === "commit")).toBe(true) const span = tracer.spans.find((s) => s.name === "opencode.tool.bash")! expect(span.attributes["input.value"]).toContain("git commit") }) @@ -43,6 +45,18 @@ describe("handleToolCalled", () => { handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: { command: "ls -la" }, executed: true }), ctx) expect(counters.commit.calls).toHaveLength(0) }) + + test("does not count unexecuted or failed git commits", () => { + const { ctx, counters } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: { command: "git commit -m test" }, executed: false }), ctx) + handleToolSuccess(evt("session.tool.success", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", content: [{ type: "text", text: "skipped" }], executed: false }), ctx) + handleToolInputStarted(inputStarted("call_2"), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_2", input: { command: "git commit -m test" }, executed: true }), ctx) + handleToolFailed(evt("session.tool.failed", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_2", error: { type: "ToolError", message: "failed" }, executed: true }), ctx) + expect(counters.commit.calls).toHaveLength(0) + }) }) describe("handleToolSuccess", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 29468c7..62761c4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -148,6 +148,8 @@ export function makeTracingState(): TracingState { sessionTotals: new Map(), countedSessions: new Set(), countedMessages: new Set(), + sessionProjects: new Map(), + stepOutputs: new Map(), pendingPrompts: new Map(), pendingPermissions: new Map(), activeLlm: new Map(), diff --git a/tests/state.test.ts b/tests/state.test.ts new file mode 100644 index 0000000..05ea516 --- /dev/null +++ b/tests/state.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect } from "bun:test" +import { loadConfig } from "../src/config.ts" +import { acquireSharedOtel, configKey } from "../src/state.ts" +import { makeCtx } from "./helpers.ts" +import { contextForSession, markSeen } from "../src/util.ts" +import type { HandlerContext } from "../src/types.ts" + +describe("multi-location telemetry", () => { + test("rejects a second location with different exporter settings", async () => { + const first = loadConfig({ enabled: true, endpoint: "http://one:4317", otlpHeaders: "Authorization=secret-a" }) + const second = loadConfig({ enabled: true, endpoint: "http://two:4317", otlpHeaders: "Authorization=secret-b" }) + const key = "__opencode_plugin_otel_shared__" + const globals = globalThis as Record + const previous = globals[key] + const fake = { configKey: configKey(first), refs: 1 } + globals[key] = fake + try { + await expect(acquireSharedOtel(second, "2.0.0")).rejects.toThrow("identical telemetry configuration") + expect(fake.refs).toBe(1) + expect(await acquireSharedOtel(first, "2.0.0")).toBe(fake as never) + expect(fake.refs).toBe(2) + } finally { + globals[key] = previous + } + }) + + test("attributes observed sessions to their own projects", async () => { + const { ctx } = makeCtx() + const base: HandlerContext = { ...ctx, commonAttrs: { team: "platform" } } + const projectFor = async (id: string) => id === "one" ? "project-one" : "project-two" + const first = await contextForSession("one", base, projectFor) + const second = await contextForSession("two", base, projectFor) + expect(first.commonAttrs["project.id"]).toBe("project-one") + expect(second.commonAttrs["project.id"]).toBe("project-two") + expect(base.commonAttrs["project.id"]).toBeUndefined() + expect((await contextForSession("unknown", base, async () => { throw new Error("not found") })).commonAttrs["project.id"]).toBeUndefined() + }) + + test("bounds session and message deduplication sets", () => { + const { ctx } = makeCtx() + for (let i = 0; i < 10_050; i++) { + markSeen(ctx.tracing.countedSessions, `s${i}`) + markSeen(ctx.tracing.countedMessages, `m${i}`) + } + expect(ctx.tracing.countedSessions.size).toBe(10_000) + expect(ctx.tracing.countedMessages.size).toBe(10_000) + }) +}) From 2c9253fb107de45504d85e2beff24a07f4480207 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:08:20 -0400 Subject: [PATCH 03/13] fix(v2): serialize event dispatch and retain session identity --- AGENTS.md | 3 ++- README.md | 3 ++- src/handlers/session.ts | 16 +++++++++++---- src/plugin.ts | 14 ++++++++----- src/state.ts | 2 ++ src/types.ts | 9 +++++++++ src/util.ts | 27 ++++++++++++++++++++----- tests/handlers/session.test.ts | 14 +++++++++++++ tests/helpers.ts | 2 ++ tests/state.test.ts | 37 ++++++++++++++++++++++++++++++++-- 10 files changed, 109 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 459979d..54ad091 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,8 @@ src/ - **`HandlerContext`** — all event handlers receive a `HandlerContext` (defined in `src/types.ts`). Do not import OTel globals directly inside handlers; thread them through the context. - **`setBoundedMap` / `markSeen`** — always use these for correlation maps (`toolMeta`, `stepMeta`, `pendingPrompts`, `pendingPermissions`, `seenEvents`) to prevent unbounded growth. - **Single source of truth for tokens/cost** — token and cost counters are incremented once per `session.step.ended`/`failed`; session totals come from `session.usage.updated` (cumulative) with a per-step fallback. -- **Event de-duplication** — V2 may deliver the same event to multiple plugin instances; dedupe by `event.id` via `markSeen`. +- **Event de-duplication** — V2 may deliver the same event to multiple plugin instances; serialize shared dispatch before deduping by `event.id` via `markSeen` so later events cannot overtake an asynchronous session lookup. +- **Session identity** — retain the agent, subagent parent, and creation time across executions, and hydrate missed `session.created` events with `ctx.session.get`. - **Multi-location configuration** — process-wide exporters require identical telemetry configuration; reject a conflicting setup and derive project attributes from the observed session, not the loading location. - **Shutdown** — providers are flushed (never shut down) on plugin cleanup and once per process on `beforeExit`. Shutting down the global OTel providers poisons them for the rest of the process. - **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_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. diff --git a/README.md b/README.md index e31e81d..97fa307 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ When OpenCode loads multiple locations in one process, all enabled instances mus use identical telemetry configuration. A conflicting endpoint, authentication, resource attribute, metric prefix, or signal option fails setup rather than exporting one location's data using another location's settings. Event attributes -use the observed session's project ID when available. +use the observed session's project ID when available. Shared event processing is +serialized so an asynchronous session lookup cannot reorder step start/end events. The environment variables (set them in your shell profile — `~/.zshrc`, `~/.bashrc`, etc.): diff --git a/src/handlers/session.ts b/src/handlers/session.ts index ab93d32..9e78698 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -75,14 +75,16 @@ function countSession(sessionID: string, isSubagent: boolean, ctx: HandlerContex export function ensureSession(sessionID: string, at: number, ctx: HandlerContext): SessionTotals { const existing = ctx.tracing.sessionTotals.get(sessionID) if (existing) return existing - countSession(sessionID, false, ctx) + const identity = ctx.tracing.sessionIdentity.get(sessionID) + countSession(sessionID, identity?.agentType === "subagent", ctx) const totals: SessionTotals = { - startMs: at, + startMs: identity?.startMs ?? at, tokens: 0, cost: 0, messages: 0, - agent: "unknown", - agentType: "primary", + agent: identity?.agent ?? "unknown", + agentType: identity?.agentType ?? "primary", + ...(identity?.parentID ? { parentID: identity.parentID } : {}), } setBoundedMap(ctx.tracing.sessionTotals, sessionID, totals) return totals @@ -96,6 +98,12 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler const agent = d.agent ?? "unknown" setBoundedMap(ctx.tracing.sessionProjects, d.sessionID, d.projectID) + setBoundedMap(ctx.tracing.sessionIdentity, d.sessionID, { + agent, + agentType, + ...(d.parentID ? { parentID: d.parentID } : {}), + startMs: e.created, + }) countSession(d.sessionID, isSubagent, ctx) if (isSubagent && isMetricEnabled("subtask.count", ctx)) { ctx.instruments.subtaskCounter.add(1, { diff --git a/src/plugin.ts b/src/plugin.ts index 15fb655..5fb58dc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,7 +6,7 @@ import { forceFlushOtel } from "./otel.ts" import { remoteParentContext } from "./trace-context.ts" import { acquireSharedOtel, acquireTracingState, flushSharedOtel, releaseSharedOtel } from "./state.ts" import { LEVELS, type HandlerContext, type Level, type OpenCodeContext, type OpenCodeEvent } from "./types.ts" -import { contextForSession, markSeen, setBoundedMap } from "./util.ts" +import { contextForSession, enqueueEvent, setBoundedMap } from "./util.ts" import { finalizeSession, handleExecutionEnded, @@ -118,7 +118,7 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> const scoped = (sessionID: string) => contextForSession( sessionID, hctx, - async (id) => (await ctx.session.get({ sessionID: id })).projectID, + async (id) => ctx.session.get({ sessionID: id }), ) await ctx.session.hook("model.request", (event) => { @@ -128,6 +128,12 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> const dispatch = async (event: OpenCodeEvent): Promise => { if (event.type === "session.created") { setBoundedMap(tracing.sessionProjects, event.data.sessionID, event.data.projectID) + setBoundedMap(tracing.sessionIdentity, event.data.sessionID, { + agent: event.data.agent ?? "unknown", + agentType: event.data.parentID ? "subagent" : "primary", + ...(event.data.parentID ? { parentID: event.data.parentID } : {}), + startMs: event.created, + }) } const sessionID = "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID @@ -210,10 +216,8 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> const running = (async () => { try { for await (const event of ctx.event.subscribe({ signal: controller.signal })) { - if (tracing.seenEvents.has(event.id)) continue - markSeen(tracing.seenEvents, event.id) try { - await dispatch(event) + await enqueueEvent(tracing, event.id, () => dispatch(event)) } catch (err) { await log("error", "otel: failed to handle event", { type: event.type, diff --git a/src/state.ts b/src/state.ts index 9888f75..017f3e8 100644 --- a/src/state.ts +++ b/src/state.ts @@ -97,6 +97,7 @@ export function acquireTracingState(): TracingState { if (!state) { state = { seenEvents: new Set(), + eventQueue: Promise.resolve(), runSpans: new Map(), runSpanContexts: new Map(), stepSpans: new Map(), @@ -109,6 +110,7 @@ export function acquireTracingState(): TracingState { countedSessions: new Set(), countedMessages: new Set(), sessionProjects: new Map(), + sessionIdentity: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), pendingPermissions: new Map(), diff --git a/src/types.ts b/src/types.ts index c772942..de1d897 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,6 +80,13 @@ export type SessionTotals = { parentID?: string } +export type SessionIdentity = { + agent: string + agentType: SessionAgentType + parentID?: string + startMs: number +} + /** Model/agent metadata for an in-flight LLM step, keyed by assistant message ID. */ export type StepMeta = { sessionID: string @@ -127,6 +134,7 @@ export type SharedOtel = { */ export type TracingState = { seenEvents: Set + eventQueue: Promise runSpans: Map runSpanContexts: Map stepSpans: Map @@ -139,6 +147,7 @@ export type TracingState = { countedSessions: Set countedMessages: Set sessionProjects: Map + sessionIdentity: Map stepOutputs: Map> pendingPrompts: Map pendingPermissions: Map diff --git a/src/util.ts b/src/util.ts index 9eda812..bbcd147 100644 --- a/src/util.ts +++ b/src/util.ts @@ -69,6 +69,16 @@ export function markSeen(seen: TracingState["seenEvents"], id: string): void { seen.add(id) } +export function enqueueEvent(state: TracingState, id: string, handle: () => Promise): Promise { + const task = state.eventQueue.then(async () => { + if (state.seenEvents.has(id)) return + await handle() + markSeen(state.seenEvents, id) + }) + state.eventQueue = task.catch(() => {}) + return task +} + /** * Returns `true` if the metric name (without prefix) is not in the disabled set. * The `name` should be the suffix after the metric prefix, e.g. `"session.count"`. @@ -127,16 +137,23 @@ export function getSessionAgentMeta( export async function contextForSession( sessionID: string, ctx: HandlerContext, - projectForSession: (sessionID: string) => Promise, + getSession: (sessionID: string) => Promise<{ projectID: string; agent?: string; parentID?: string; time: { created: number } }>, ): Promise { let projectID = ctx.tracing.sessionProjects.get(sessionID) - if (!projectID) { + if (!projectID || !ctx.tracing.sessionIdentity.has(sessionID)) { try { - projectID = await projectForSession(sessionID) + const session = await getSession(sessionID) + projectID = session.projectID setBoundedMap(ctx.tracing.sessionProjects, sessionID, projectID) + setBoundedMap(ctx.tracing.sessionIdentity, sessionID, { + agent: session.agent ?? "unknown", + agentType: session.parentID ? "subagent" : "primary", + ...(session.parentID ? { parentID: session.parentID } : {}), + startMs: session.time.created, + }) } catch { - return ctx + if (!projectID) return ctx } } - return { ...ctx, commonAttrs: { ...ctx.commonAttrs, "project.id": projectID } } + return projectID ? { ...ctx, commonAttrs: { ...ctx.commonAttrs, "project.id": projectID } } : ctx } diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index 782eeb5..c70664f 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -90,6 +90,20 @@ describe("handleExecutionStarted", () => { expect(ctx.tracing.sessionTotals.has("ses_new")).toBe(true) expect(counters.session.calls).toHaveLength(1) }) + + test("keeps a subagent identity across executions", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "parent", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "parent" }), ctx) + handleSessionCreated(evt("session.created", { sessionID: "sub", parentID: "parent", agent: "explore", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "sub" }), ctx) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "sub" }), ctx, { type: "succeeded" }) + finalizeSession("sub", ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "sub" }), ctx) + expect(ctx.tracing.sessionTotals.get("sub")?.agentType).toBe("subagent") + expect(ctx.tracing.sessionTotals.get("sub")?.agent).toBe("explore") + expect(tracer.spans.at(-1)?.parentSpan).toBe(tracer.spans[0]) + }) }) describe("handleExecutionEnded", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 62761c4..7077b8f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -137,6 +137,7 @@ export function makeTracer(): SpyTracer { export function makeTracingState(): TracingState { return { seenEvents: new Set(), + eventQueue: Promise.resolve(), runSpans: new Map(), runSpanContexts: new Map(), stepSpans: new Map(), @@ -149,6 +150,7 @@ export function makeTracingState(): TracingState { countedSessions: new Set(), countedMessages: new Set(), sessionProjects: new Map(), + sessionIdentity: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), pendingPermissions: new Map(), diff --git a/tests/state.test.ts b/tests/state.test.ts index 05ea516..7c6d97c 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -2,8 +2,10 @@ import { describe, test, expect } from "bun:test" import { loadConfig } from "../src/config.ts" import { acquireSharedOtel, configKey } from "../src/state.ts" import { makeCtx } from "./helpers.ts" -import { contextForSession, markSeen } from "../src/util.ts" +import { contextForSession, enqueueEvent, markSeen } from "../src/util.ts" import type { HandlerContext } from "../src/types.ts" +import { handleExecutionStarted } from "../src/handlers/session.ts" +import { evt } from "./helpers.ts" describe("multi-location telemetry", () => { test("rejects a second location with different exporter settings", async () => { @@ -27,7 +29,7 @@ describe("multi-location telemetry", () => { test("attributes observed sessions to their own projects", async () => { const { ctx } = makeCtx() const base: HandlerContext = { ...ctx, commonAttrs: { team: "platform" } } - const projectFor = async (id: string) => id === "one" ? "project-one" : "project-two" + const projectFor = async (id: string) => ({ projectID: id === "one" ? "project-one" : "project-two", time: { created: 100 } }) const first = await contextForSession("one", base, projectFor) const second = await contextForSession("two", base, projectFor) expect(first.commonAttrs["project.id"]).toBe("project-one") @@ -36,6 +38,37 @@ describe("multi-location telemetry", () => { expect((await contextForSession("unknown", base, async () => { throw new Error("not found") })).commonAttrs["project.id"]).toBeUndefined() }) + test("hydrates a resumed subagent when session.created was missed", async () => { + const { ctx } = makeCtx() + const scoped = await contextForSession("sub", ctx, async () => ({ + projectID: "other-project", + agent: "explore", + parentID: "parent", + time: { created: 500 }, + })) + handleExecutionStarted(evt("session.execution.started", { sessionID: "sub" }, 1000), scoped) + expect(ctx.tracing.sessionTotals.get("sub")).toMatchObject({ + agent: "explore", agentType: "subagent", parentID: "parent", startMs: 500, + }) + expect(scoped.commonAttrs["project.id"]).toBe("other-project") + }) + + test("serializes duplicate subscribers across an asynchronous lookup", async () => { + const { ctx } = makeCtx() + const order: string[] = [] + let release!: () => void + const lookup = new Promise((resolve) => { release = resolve }) + const started = enqueueEvent(ctx.tracing, "start", async () => { + await lookup + order.push("start") + }) + const duplicate = enqueueEvent(ctx.tracing, "start", async () => { order.push("duplicate") }) + const ended = enqueueEvent(ctx.tracing, "end", async () => { order.push("end") }) + release() + await Promise.all([started, duplicate, ended]) + expect(order).toEqual(["start", "end"]) + }) + test("bounds session and message deduplication sets", () => { const { ctx } = makeCtx() for (let i = 0; i < 10_050; i++) { From 3083950234edaf8d540910a5ff1e61dc8006d778 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:15:03 -0400 Subject: [PATCH 04/13] fix(v2): coordinate request spans and preserve queued prompts --- src/handlers/chat-headers.ts | 33 ++++++++++++++++++-- src/handlers/session.ts | 18 +++++++++-- src/handlers/step.ts | 56 +++++++++++++++++++++------------- src/otel.ts | 7 +++-- src/plugin.ts | 4 +-- src/state.ts | 3 ++ src/types.ts | 4 ++- tests/chat-headers.test.ts | 35 +++++++++++++++------ tests/handlers/session.test.ts | 23 ++++++++++++-- tests/helpers.ts | 2 ++ tests/otel.test.ts | 6 ++++ 11 files changed, 145 insertions(+), 46 deletions(-) diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index 8c76f19..eb5fa31 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -1,5 +1,8 @@ +import { SpanKind } from "@opentelemetry/api" +import { AGENT_NAME, LLM_MODEL_NAME, LLM_PROVIDER, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions" import type { HandlerContext } from "../types.ts" import { injectTraceContext } from "../trace-context.ts" +import { genAiProviderName, isTraceEnabled, resolveRunContext, setBoundedMap } from "../util.ts" /** The `session.hook("model.request", ...)` event shape consumed by this handler. */ export type ModelRequestEvent = { @@ -11,11 +14,35 @@ export type ModelRequestEvent = { } /** Injects the active LLM span's W3C trace context into outbound model requests. */ -export function handleModelRequest(event: ModelRequestEvent, ctx: HandlerContext): void { +export async function handleModelRequest(event: ModelRequestEvent, ctx: HandlerContext): Promise { if (event.kind !== "primary") return const providerID = event.model.providerID if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return - const active = ctx.tracing.activeLlm.get(event.sessionID) - if (!active || active.providerID !== providerID || active.modelID !== event.model.id || active.agent !== event.agent) return + if (!isTraceEnabled("llm", ctx)) return + await ctx.tracing.eventQueue + let active = ctx.tracing.activeLlm.get(event.sessionID) + if (!active) { + const span = ctx.tracer.startSpan( + `${ctx.tracePrefix}llm`, + { + startTime: Date.now(), + kind: SpanKind.CLIENT, + attributes: { + [SemanticConventions.OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, + [SESSION_ID]: event.sessionID, + [AGENT_NAME]: event.agent, + [LLM_MODEL_NAME]: event.model.id, + [LLM_PROVIDER]: providerID, + "gen_ai.provider.name": genAiProviderName(providerID), + ...ctx.commonAttrs, + }, + }, + resolveRunContext(event.sessionID, ctx), + ) + setBoundedMap(ctx.tracing.provisionalLlm, event.sessionID, span) + active = { agent: event.agent, modelID: event.model.id, providerID, spanContext: span.spanContext() } + setBoundedMap(ctx.tracing.activeLlm, event.sessionID, active) + } + if (active.providerID !== providerID || active.modelID !== event.model.id || active.agent !== event.agent) return injectTraceContext(active.spanContext, event.headers) } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9e78698..9a0a274 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -33,7 +33,9 @@ export function handlePromptEnqueued(e: EventOf<"session.inbox.enqueued">, ctx: for (const agent of item.payload.agents ?? []) parts.push(agent.name) for (const skill of item.payload.skills ?? []) parts.push(skill.name) const text = parts.filter(Boolean).join("\n") - setBoundedMap(ctx.tracing.pendingPrompts, sessionID, { text, startMs: e.created }) + const queue = ctx.tracing.pendingPrompts.get(sessionID) ?? [] + if (queue.length < 100) queue.push({ text, startMs: e.created }) + setBoundedMap(ctx.tracing.pendingPrompts, sessionID, queue) const totals = ctx.tracing.sessionTotals.get(sessionID) ctx.emitLog({ severityNumber: SeverityNumber.INFO, @@ -161,7 +163,10 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler export function handleExecutionStarted(e: EventOf<"session.execution.started">, ctx: HandlerContext) { const sessionID = e.data.sessionID const totals = ensureSession(sessionID, e.created, ctx) - const pendingPrompt = ctx.tracing.pendingPrompts.get(sessionID) + const queue = ctx.tracing.pendingPrompts.get(sessionID) + const pendingPrompt = queue?.shift() + if (queue?.length === 0) ctx.tracing.pendingPrompts.delete(sessionID) + if (pendingPrompt) setBoundedMap(ctx.tracing.activePrompts, sessionID, pendingPrompt) if (!isTraceEnabled("session", ctx)) return @@ -313,7 +318,7 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { } ctx.tracing.sessionTotals.delete(sessionID) ctx.tracing.activeLlm.delete(sessionID) - ctx.tracing.pendingPrompts.delete(sessionID) + ctx.tracing.activePrompts.delete(sessionID) const attrs = { ...ctx.commonAttrs, "session.id": sessionID } const durationMs = Date.now() - totals.startMs @@ -354,6 +359,13 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { /** Ends and clears any dangling step/tool spans for a session execution. */ function sweepExecution(sessionID: string, ctx: HandlerContext) { + const provisional = ctx.tracing.provisionalLlm.get(sessionID) + if (provisional) { + provisional.setStatus({ code: SpanStatusCode.ERROR, message: "execution ended before step started" }) + provisional.end() + ctx.tracing.provisionalLlm.delete(sessionID) + ctx.tracing.activeLlm.delete(sessionID) + } for (const [callID, span] of ctx.tracing.toolSpans) { if (ctx.tracing.toolMeta.get(callID)?.sessionID !== sessionID) continue span.setStatus({ code: SpanStatusCode.ERROR, message: "session execution ended before tool completed" }) diff --git a/src/handlers/step.ts b/src/handlers/step.ts index 1525f17..ac0d7dd 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -83,34 +83,43 @@ export function handleStepStarted(e: EventOf<"session.step.started">, ctx: Handl } if (!isTraceEnabled("llm", ctx)) return - const promptText = ctx.tracing.pendingPrompts.get(d.sessionID)?.text - - const span = ctx.tracer.startSpan( + const promptText = ctx.tracing.activePrompts.get(d.sessionID)?.text + const attributes = { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, + [SESSION_ID]: d.sessionID, + [AGENT_NAME]: d.agent, + "agent.type": agentType, + [LLM_SYSTEM]: d.model.providerID, + [LLM_PROVIDER]: d.model.providerID, + "gen_ai.provider.name": genAiProviderName(d.model.providerID), + [LLM_MODEL_NAME]: d.model.id, + ...(promptText + ? { + [INPUT_VALUE]: promptText, + [INPUT_MIME_TYPE]: MimeType.TEXT, + [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), + } + : {}), + ...ctx.commonAttrs, + } + const active = ctx.tracing.activeLlm.get(d.sessionID) + const provisional = ctx.tracing.provisionalLlm.get(d.sessionID) + ctx.tracing.provisionalLlm.delete(d.sessionID) + const matches = provisional && active?.agent === d.agent && active.modelID === d.model.id && active.providerID === d.model.providerID + if (provisional && !matches) { + provisional.setStatus({ code: SpanStatusCode.ERROR, message: "model request did not match step" }) + provisional.end() + } + const span = matches ? provisional : ctx.tracer.startSpan( `${ctx.tracePrefix}llm`, { startTime: d.started ?? e.created, kind: SpanKind.CLIENT, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, - [SESSION_ID]: d.sessionID, - [AGENT_NAME]: d.agent, - "agent.type": agentType, - [LLM_SYSTEM]: d.model.providerID, - [LLM_PROVIDER]: d.model.providerID, - "gen_ai.provider.name": genAiProviderName(d.model.providerID), - [LLM_MODEL_NAME]: d.model.id, - ...(promptText - ? { - [INPUT_VALUE]: promptText, - [INPUT_MIME_TYPE]: MimeType.TEXT, - [LLM_INPUT_MESSAGES]: JSON.stringify([{ role: "user", content: promptText }]), - } - : {}), - ...ctx.commonAttrs, - }, + attributes, }, resolveRunContext(d.sessionID, ctx), ) + if (matches) span.setAttributes(attributes) setBoundedMap(ctx.tracing.stepSpans, d.assistantMessageID, span) setBoundedMap(ctx.tracing.stepSpanContexts, d.assistantMessageID, span.spanContext()) setBoundedMap(ctx.tracing.activeLlm, d.sessionID, { @@ -292,9 +301,12 @@ function recordUsageMetrics( } function cleanupStep(assistantMessageID: string, sessionID: string, ctx: HandlerContext) { + const spanContext = ctx.tracing.stepSpanContexts.get(assistantMessageID) ctx.tracing.stepSpans.delete(assistantMessageID) ctx.tracing.stepSpanContexts.delete(assistantMessageID) ctx.tracing.stepMeta.delete(assistantMessageID) - ctx.tracing.activeLlm.delete(sessionID) + if (spanContext && ctx.tracing.activeLlm.get(sessionID)?.spanContext.spanId === spanContext.spanId) { + ctx.tracing.activeLlm.delete(sessionID) + } ctx.tracing.stepOutputs.delete(assistantMessageID) } diff --git a/src/otel.ts b/src/otel.ts index 64c9d59..2a032c0 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -32,13 +32,13 @@ import { * `host.arch`. Additional attributes from `OTEL_RESOURCE_ATTRIBUTES` are merged in and * may override the defaults. */ -export function buildResource(version: string) { +export function buildResource(version: string, resourceAttributes = process.env["OTEL_RESOURCE_ATTRIBUTES"]) { const attrs: Record = { [ATTR_SERVICE_NAME]: "opencode", "app.version": version, "os.type": process.platform, [ATTR_HOST_ARCH]: process.arch, - ...parseAttributePairs(process.env["OTEL_RESOURCE_ATTRIBUTES"]), + ...parseAttributePairs(resourceAttributes), } return resourceFromAttributes(attrs) } @@ -78,8 +78,9 @@ export async function setupOtel( version: string, otlpHeaders?: string, otlpHeadersHelper?: string, + resourceAttributes?: string, ): Promise { - const resource = buildResource(version) + const resource = buildResource(version, resourceAttributes ?? "") const staticHeaders = parseOtlpHeaders(otlpHeaders) const dynamicHeaders = new DynamicHeaders(staticHeaders, otlpHeadersHelper) if (otlpHeadersHelper) { diff --git a/src/plugin.ts b/src/plugin.ts index 5fb58dc..1290b7f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -121,8 +121,8 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> async (id) => ctx.session.get({ sessionID: id }), ) - await ctx.session.hook("model.request", (event) => { - handleModelRequest(event, hctx) + await ctx.session.hook("model.request", async (event) => { + await handleModelRequest(event, await scoped(event.sessionID)) }) const dispatch = async (event: OpenCodeEvent): Promise => { diff --git a/src/state.ts b/src/state.ts index 017f3e8..8cf44ab 100644 --- a/src/state.ts +++ b/src/state.ts @@ -52,6 +52,7 @@ export async function acquireSharedOtel(config: PluginConfig, version: string): version, config.otlpHeaders, config.otlpHeadersHelper, + config.resourceAttributes, ) const shared: SharedOtel = { providers, @@ -113,8 +114,10 @@ export function acquireTracingState(): TracingState { sessionIdentity: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), + activePrompts: new Map(), pendingPermissions: new Map(), activeLlm: new Map(), + provisionalLlm: new Map(), } g[TRACING_KEY] = state } diff --git a/src/types.ts b/src/types.ts index de1d897..bf63eb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,9 +149,11 @@ export type TracingState = { sessionProjects: Map sessionIdentity: Map stepOutputs: Map> - pendingPrompts: Map + pendingPrompts: Map + activePrompts: Map pendingPermissions: Map activeLlm: Map + provisionalLlm: Map } /** Shared context threaded through every event handler. */ diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index df76969..83f870c 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test" import { handleModelRequest } from "../src/handlers/chat-headers.ts" -import { makeCtx } from "./helpers.ts" +import { handleStepStarted } from "../src/handlers/step.ts" +import { makeCtx, evt } from "./helpers.ts" const SPAN_CONTEXT = { traceId: "0af7651916cd43dd8448eb211c80319c", @@ -9,38 +10,54 @@ const SPAN_CONTEXT = { } describe("handleModelRequest", () => { - test("injects traceparent for a configured provider", () => { + test("injects traceparent for a configured provider", async () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("litellm") ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "litellm", spanContext: SPAN_CONTEXT }) const headers: Record = {} - handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "litellm", id: "m" }, kind: "primary", headers }, ctx) + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "litellm", id: "m" }, kind: "primary", headers }, ctx) expect(headers["traceparent"]).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") }) - test("does nothing when the provider is not configured", () => { + test("does nothing when the provider is not configured", async () => { const { ctx } = makeCtx() ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "openai", spanContext: SPAN_CONTEXT }) const headers: Record = {} - handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "openai", id: "m" }, kind: "primary", headers }, ctx) + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "openai", id: "m" }, kind: "primary", headers }, ctx) expect(headers["traceparent"]).toBeUndefined() }) - test("supports the wildcard provider", () => { + test("supports the wildcard provider", async () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("*") ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "vllm", spanContext: SPAN_CONTEXT }) const headers: Record = {} - handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) expect(headers["traceparent"]).toBeDefined() }) - test("does not propagate a primary step into an auxiliary model request", () => { + test("does not propagate a primary step into an auxiliary model request", async () => { const { ctx } = makeCtx() ctx.tracePropagationProviders.add("*") ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "vllm", spanContext: SPAN_CONTEXT }) const headers: Record = {} - handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "title", headers }, ctx) + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "title", headers }, ctx) expect(headers["traceparent"]).toBeUndefined() }) + + test("waits for pending event processing and adopts a provisional span", async () => { + const { ctx, tracer } = makeCtx() + ctx.tracePropagationProviders.add("*") + let release!: () => void + ctx.tracing.eventQueue = new Promise((resolve) => { release = resolve }) + const headers: Record = {} + const request = handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) + expect(headers["traceparent"]).toBeUndefined() + release() + await request + expect(headers["traceparent"]).toBeDefined() + handleStepStarted(evt("session.step.started", { sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "vllm" }, started: 100 }), ctx) + expect(tracer.spans.filter((span) => span.name === "opencode.llm")).toHaveLength(1) + expect(ctx.tracing.stepSpans.get("msg_1")?.spanContext().spanId).toBe(ctx.tracing.activeLlm.get("ses_1")?.spanContext.spanId) + }) }) diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index c70664f..4bf1fc1 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -47,7 +47,7 @@ describe("handlePromptEnqueued", () => { expect(logger.records[0]!.body).toBe("user_prompt") expect(logger.records[0]!.attributes?.["delivery"]).toBe("queue") expect(logger.records[0]!.attributes?.["prompt"]).toBeUndefined() - expect(ctx.tracing.pendingPrompts.get("ses_1")!.text).toBe("hello") + expect(ctx.tracing.pendingPrompts.get("ses_1")![0]!.text).toBe("hello") }) test("skips non-user inbox items", () => { @@ -63,14 +63,14 @@ describe("handleExecutionStarted", () => { test("starts a run span and attaches the pending prompt", () => { const { ctx, tracer } = makeCtx() handleSessionCreated(evt("session.created", { sessionID: "ses_1" }), ctx) - ctx.tracing.pendingPrompts.set("ses_1", { text: "hello", startMs: 1 }) + ctx.tracing.pendingPrompts.set("ses_1", [{ text: "hello", startMs: 1 }]) handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }, 2000), ctx) const span = tracer.spans[0]! expect(span.name).toBe("opencode.session") expect(span.startTime).toBe(2000) expect(span.attributes["input.value"]).toBe("hello") expect(ctx.tracing.runSpans.has("ses_1")).toBe(true) - expect(ctx.tracing.pendingPrompts.get("ses_1")!.text).toBe("hello") + expect(ctx.tracing.activePrompts.get("ses_1")!.text).toBe("hello") }) test("nests subagent runs under the parent run span", () => { @@ -104,6 +104,23 @@ describe("handleExecutionStarted", () => { expect(ctx.tracing.sessionTotals.get("sub")?.agent).toBe("explore") expect(tracer.spans.at(-1)?.parentSpan).toBe(tracer.spans[0]) }) + + test("retains a prompt queued during the current execution for the next run", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p" }), ctx) + const prompt = (text: string) => evt("session.inbox.enqueued", { + sessionID: "ses_1", inboxID: text, + item: { type: "user", payload: { text }, delivery: "queue" }, + }) + handlePromptEnqueued(prompt("first"), ctx, false) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handlePromptEnqueued(prompt("second"), ctx, false) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "ses_1" }), ctx, { type: "succeeded" }) + finalizeSession("ses_1", ctx) + expect(ctx.tracing.pendingPrompts.get("ses_1")?.[0]?.text).toBe("second") + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans.at(-1)?.attributes["input.value"]).toBe("second") + }) }) describe("handleExecutionEnded", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 7077b8f..cdaa023 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -153,8 +153,10 @@ export function makeTracingState(): TracingState { sessionIdentity: new Map(), stepOutputs: new Map(), pendingPrompts: new Map(), + activePrompts: new Map(), pendingPermissions: new Map(), activeLlm: new Map(), + provisionalLlm: new Map(), } } diff --git a/tests/otel.test.ts b/tests/otel.test.ts index e49fdd2..9cd2fab 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -82,6 +82,12 @@ describe("buildResource", () => { const resource = buildResource("0.0.1") expect(resource.attributes["service.name"]).toBe("my-override") }) + + test("explicit accepted attributes ignore a rejected location's environment", () => { + process.env["OTEL_RESOURCE_ATTRIBUTES"] = "team=rejected" + expect(buildResource("2.0.0", "team=accepted").attributes["team"]).toBe("accepted") + expect(buildResource("2.0.0", "").attributes["team"]).toBeUndefined() + }) }) describe("setupOtel", () => { From 371c4ab46357d5ace483584bd41833534780d251 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:20:46 -0400 Subject: [PATCH 05/13] fix(v2): keep steering prompts scoped and flush outside dispatch --- src/handlers/session.ts | 17 ++++++++++++++--- src/plugin.ts | 13 +++++++------ src/state.ts | 9 +++++++++ src/types.ts | 1 + tests/handlers/session.test.ts | 20 ++++++++++++++++++++ tests/helpers.ts | 1 + tests/state.test.ts | 15 ++++++++++++++- 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9a0a274..989d6a9 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -33,9 +33,17 @@ export function handlePromptEnqueued(e: EventOf<"session.inbox.enqueued">, ctx: for (const agent of item.payload.agents ?? []) parts.push(agent.name) for (const skill of item.payload.skills ?? []) parts.push(skill.name) const text = parts.filter(Boolean).join("\n") - const queue = ctx.tracing.pendingPrompts.get(sessionID) ?? [] - if (queue.length < 100) queue.push({ text, startMs: e.created }) - setBoundedMap(ctx.tracing.pendingPrompts, sessionID, queue) + if (item.delivery === "steer" && ctx.tracing.activeExecutions.has(sessionID)) { + const active = ctx.tracing.activePrompts.get(sessionID) + setBoundedMap(ctx.tracing.activePrompts, sessionID, { + text: active?.text ? `${active.text}\n${text}` : text, + startMs: active?.startMs ?? e.created, + }) + } else { + const queue = ctx.tracing.pendingPrompts.get(sessionID) ?? [] + if (queue.length < 100) queue.push({ text, startMs: e.created }) + setBoundedMap(ctx.tracing.pendingPrompts, sessionID, queue) + } const totals = ctx.tracing.sessionTotals.get(sessionID) ctx.emitLog({ severityNumber: SeverityNumber.INFO, @@ -162,6 +170,7 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler /** Starts the root run span for a single execution (user turn), keyed by session id. */ export function handleExecutionStarted(e: EventOf<"session.execution.started">, ctx: HandlerContext) { const sessionID = e.data.sessionID + markSeen(ctx.tracing.activeExecutions, sessionID) const totals = ensureSession(sessionID, e.created, ctx) const queue = ctx.tracing.pendingPrompts.get(sessionID) const pendingPrompt = queue?.shift() @@ -219,6 +228,7 @@ export function handleExecutionEnded( ) { const sessionID = e.data.sessionID const totals = ctx.tracing.sessionTotals.get(sessionID) + ctx.tracing.activeExecutions.delete(sessionID) sweepExecution(sessionID, ctx) @@ -319,6 +329,7 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { ctx.tracing.sessionTotals.delete(sessionID) ctx.tracing.activeLlm.delete(sessionID) ctx.tracing.activePrompts.delete(sessionID) + ctx.tracing.activeExecutions.delete(sessionID) const attrs = { ...ctx.commonAttrs, "session.id": sessionID } const durationMs = Date.now() - totals.startMs diff --git a/src/plugin.ts b/src/plugin.ts index 1290b7f..a2865e5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,7 +4,7 @@ import { loadConfig, parseAttributePairs, resolveHelperPath, resolveLogLevel, ty import { probeEndpoint } from "./probe.ts" import { forceFlushOtel } from "./otel.ts" import { remoteParentContext } from "./trace-context.ts" -import { acquireSharedOtel, acquireTracingState, flushSharedOtel, releaseSharedOtel } from "./state.ts" +import { acquireSharedOtel, acquireTracingState, createFlushScheduler, flushSharedOtel, releaseSharedOtel } from "./state.ts" import { LEVELS, type HandlerContext, type Level, type OpenCodeContext, type OpenCodeEvent } from "./types.ts" import { contextForSession, enqueueEvent, setBoundedMap } from "./util.ts" import { @@ -74,6 +74,7 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> const shared = await acquireSharedOtel(config, PLUGIN_VERSION) const tracing = acquireTracingState() + const flush = createFlushScheduler(() => forceFlushOtel(shared.providers)) await log("info", "OTel SDK initialized") const g = globalThis as Record @@ -152,24 +153,24 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> case "session.execution.succeeded": handleExecutionEnded(event, eventCtx, { type: "succeeded" }) finalizeSession(event.data.sessionID, eventCtx) - await forceFlushOtel(shared.providers) + flush.request() break case "session.execution.failed": handleExecutionEnded(event, eventCtx, { type: "failed", error: event.data.error }) finalizeSession(event.data.sessionID, eventCtx) - await forceFlushOtel(shared.providers) + flush.request() break case "session.execution.interrupted": handleExecutionEnded(event, eventCtx, { type: "interrupted", reason: event.data.reason }) finalizeSession(event.data.sessionID, eventCtx) - await forceFlushOtel(shared.providers) + flush.request() break case "session.status": handleSessionStatus(event, eventCtx) break case "session.idle": handleSessionIdle(event, eventCtx) - await forceFlushOtel(shared.providers) + flush.request() break case "session.usage.updated": handleUsageUpdated(event, eventCtx) @@ -237,7 +238,7 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> return async () => { controller.abort() await running.catch(() => {}) - await forceFlushOtel(shared.providers) + await flush.drain() await releaseSharedOtel() } } diff --git a/src/state.ts b/src/state.ts index 8cf44ab..55b6ffe 100644 --- a/src/state.ts +++ b/src/state.ts @@ -26,6 +26,14 @@ export function configKey(config: PluginConfig): string { return createHash("sha256").update(normalized).digest("hex") } +export function createFlushScheduler(flush: () => Promise) { + let pending = Promise.resolve() + return { + request() { pending = pending.then(flush, flush) }, + drain() { return pending }, + } +} + /** * Returns the process-wide shared OTel SDK instance, creating it on first use. * OpenCode may load one plugin instance per location, but `setGlobalMeterProvider` @@ -115,6 +123,7 @@ export function acquireTracingState(): TracingState { stepOutputs: new Map(), pendingPrompts: new Map(), activePrompts: new Map(), + activeExecutions: new Set(), pendingPermissions: new Map(), activeLlm: new Map(), provisionalLlm: new Map(), diff --git a/src/types.ts b/src/types.ts index bf63eb7..d8cc661 100644 --- a/src/types.ts +++ b/src/types.ts @@ -151,6 +151,7 @@ export type TracingState = { stepOutputs: Map> pendingPrompts: Map activePrompts: Map + activeExecutions: Set pendingPermissions: Map activeLlm: Map provisionalLlm: Map diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index 4bf1fc1..e02746c 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -121,6 +121,26 @@ describe("handleExecutionStarted", () => { handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) expect(tracer.spans.at(-1)?.attributes["input.value"]).toBe("second") }) + + test("applies a steering prompt to the current run, not the next run", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p" }), ctx) + handlePromptEnqueued(evt("session.inbox.enqueued", { + sessionID: "ses_1", inboxID: "first", item: { type: "user", payload: { text: "first" }, delivery: "steer" }, + }), ctx, false) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handlePromptEnqueued(evt("session.inbox.enqueued", { + sessionID: "ses_1", inboxID: "steering", item: { type: "user", payload: { text: "steering" }, delivery: "steer" }, + }), ctx, false) + handlePromptEnqueued(evt("session.inbox.enqueued", { + sessionID: "ses_1", inboxID: "second", item: { type: "user", payload: { text: "second" }, delivery: "queue" }, + }), ctx, false) + expect(ctx.tracing.activePrompts.get("ses_1")?.text).toBe("first\nsteering") + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "ses_1" }), ctx, { type: "succeeded" }) + finalizeSession("ses_1", ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans.at(-1)?.attributes["input.value"]).toBe("second") + }) }) describe("handleExecutionEnded", () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index cdaa023..87030d9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -154,6 +154,7 @@ export function makeTracingState(): TracingState { stepOutputs: new Map(), pendingPrompts: new Map(), activePrompts: new Map(), + activeExecutions: new Set(), pendingPermissions: new Map(), activeLlm: new Map(), provisionalLlm: new Map(), diff --git a/tests/state.test.ts b/tests/state.test.ts index 7c6d97c..3800cbc 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test" import { loadConfig } from "../src/config.ts" -import { acquireSharedOtel, configKey } from "../src/state.ts" +import { acquireSharedOtel, configKey, createFlushScheduler } from "../src/state.ts" import { makeCtx } from "./helpers.ts" import { contextForSession, enqueueEvent, markSeen } from "../src/util.ts" import type { HandlerContext } from "../src/types.ts" @@ -69,6 +69,19 @@ describe("multi-location telemetry", () => { expect(order).toEqual(["start", "end"]) }) + test("does not block the shared event queue on a pending exporter flush", async () => { + const { ctx } = makeCtx() + let release!: () => void + const wait = new Promise((resolve) => { release = resolve }) + const flush = createFlushScheduler(() => wait) + await enqueueEvent(ctx.tracing, "end", async () => { flush.request() }) + let started = false + await enqueueEvent(ctx.tracing, "next", async () => { started = true }) + expect(started).toBe(true) + release() + await flush.drain() + }) + test("bounds session and message deduplication sets", () => { const { ctx } = makeCtx() for (let i = 0; i < 10_050; i++) { From 6fb15a85f973ee40cf39d699522dfaf5c1207940 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:26:29 -0400 Subject: [PATCH 06/13] fix(v2): retain cumulative totals and pin exporter temporality --- README.md | 2 +- src/handlers/session.ts | 15 ++++++++++++--- src/otel.ts | 18 +++++++++++++----- src/state.ts | 1 + src/types.ts | 3 +++ tests/handlers/session.test.ts | 15 +++++++++++++++ tests/otel.test.ts | 14 ++++++++++++++ 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 97fa307..c9b799e 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `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_SPAN_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs attached to every emitted span, log event, and metric data point. Example: `team=platform,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_OTLP_METRICS_TEMPORALITY` | *(unset)* | Metrics aggregation temporality: `delta`, `cumulative`, or `lowmemory`. Required for Datadog (`delta`). Passed to the exporter and copied to `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`. | | `OPENCODE_TRACEPARENT` | *(unset)* | W3C [`traceparent`](https://www.w3.org/TR/trace-context/#traceparent-header) string. When set, all spans are parented under this remote context so opencode traces nest inside a caller's trace (e.g. a CI job). Invalid values are logged and ignored. Note: with the default `ParentBased` sampler, a value with the sampled flag off (`...-00`) suppresses all trace export. | | `OPENCODE_TRACESTATE` | *(unset)* | W3C [`tracestate`](https://www.w3.org/TR/trace-context/#tracestate-header) string, parsed alongside `OPENCODE_TRACEPARENT` and attached to the remote parent context. Ignored unless a valid `OPENCODE_TRACEPARENT` is also set. | | `OPENCODE_TRACE_PROPAGATION_PROVIDERS` | *(unset)* | Comma-separated opencode provider IDs that receive W3C `traceparent` and `tracestate` headers on LLM requests. Use `*` to explicitly enable every provider. | diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 989d6a9..f496c92 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -89,9 +89,9 @@ export function ensureSession(sessionID: string, at: number, ctx: HandlerContext countSession(sessionID, identity?.agentType === "subagent", ctx) const totals: SessionTotals = { startMs: identity?.startMs ?? at, - tokens: 0, - cost: 0, - messages: 0, + tokens: identity?.tokens ?? 0, + cost: identity?.cost ?? 0, + messages: identity?.messages ?? 0, agent: identity?.agent ?? "unknown", agentType: identity?.agentType ?? "primary", ...(identity?.parentID ? { parentID: identity.parentID } : {}), @@ -326,6 +326,15 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { sweepExecution(sessionID, ctx) return } + const identity = ctx.tracing.sessionIdentity.get(sessionID) + if (identity) { + setBoundedMap(ctx.tracing.sessionIdentity, sessionID, { + ...identity, + tokens: totals.tokens, + cost: totals.cost, + messages: totals.messages, + }) + } ctx.tracing.sessionTotals.delete(sessionID) ctx.tracing.activeLlm.delete(sessionID) ctx.tracing.activePrompts.delete(sessionID) diff --git a/src/otel.ts b/src/otel.ts index 2a032c0..456db7c 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -8,7 +8,7 @@ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc" import { OTLPLogExporter as OTLPHttpLogExporter } from "@opentelemetry/exporter-logs-otlp-http" import { OTLPLogExporter as OTLPProtoLogExporter } from "@opentelemetry/exporter-logs-otlp-proto" -import { OTLPMetricExporter as OTLPHttpMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http" +import { OTLPMetricExporter as OTLPHttpMetricExporter, AggregationTemporalityPreference } from "@opentelemetry/exporter-metrics-otlp-http" import { OTLPMetricExporter as OTLPProtoMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto" import { OTLPTraceExporter as OTLPHttpTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" import { OTLPTraceExporter as OTLPProtoTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto" @@ -16,7 +16,7 @@ import { resourceFromAttributes } from "@opentelemetry/resources" import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions" import { ATTR_HOST_ARCH } from "@opentelemetry/semantic-conventions/incubating" import type { Instruments } from "./types.ts" -import { parseAttributePairs } from "./config.ts" +import { parseAttributePairs, type MetricsTemporality } from "./config.ts" import { createGrpcMetadata, DynamicHeaders, @@ -65,6 +65,12 @@ export function buildHttpSignalUrl(endpoint: string, signal: "traces" | "metrics return url.toString() } +function metricTemporalityPreference(value: MetricsTemporality | undefined): AggregationTemporalityPreference { + if (value === "delta") return AggregationTemporalityPreference.DELTA + if (value === "lowmemory") return AggregationTemporalityPreference.LOWMEMORY + return AggregationTemporalityPreference.CUMULATIVE +} + /** * Initialises the OTel SDK — creates a `MeterProvider`, `LoggerProvider`, and * `BasicTracerProvider` backed by OTLP exporters (gRPC or HTTP/protobuf) @@ -79,10 +85,12 @@ export async function setupOtel( otlpHeaders?: string, otlpHeadersHelper?: string, resourceAttributes?: string, + metricsTemporality?: MetricsTemporality, ): Promise { const resource = buildResource(version, resourceAttributes ?? "") const staticHeaders = parseOtlpHeaders(otlpHeaders) const dynamicHeaders = new DynamicHeaders(staticHeaders, otlpHeadersHelper) + const temporalityPreference = metricTemporalityPreference(metricsTemporality) if (otlpHeadersHelper) { try { await dynamicHeaders.refresh() @@ -91,10 +99,10 @@ export async function setupOtel( } } const makeMetricExporter = (headers: HeadersMap) => protocol === "http/protobuf" - ? new OTLPProtoMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers }) + ? new OTLPProtoMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers, temporalityPreference }) : protocol === "http/json" - ? new OTLPHttpMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers }) - : new OTLPMetricExporter({ url: endpoint, metadata: createGrpcMetadata(headers) }) + ? new OTLPHttpMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers, temporalityPreference }) + : new OTLPMetricExporter({ url: endpoint, metadata: createGrpcMetadata(headers), temporalityPreference }) const makeLogExporter = (headers: HeadersMap) => protocol === "http/protobuf" ? new OTLPProtoLogExporter({ url: buildHttpSignalUrl(endpoint, "logs"), headers }) : protocol === "http/json" diff --git a/src/state.ts b/src/state.ts index 55b6ffe..3380db5 100644 --- a/src/state.ts +++ b/src/state.ts @@ -61,6 +61,7 @@ export async function acquireSharedOtel(config: PluginConfig, version: string): config.otlpHeaders, config.otlpHeadersHelper, config.resourceAttributes, + config.metricsTemporality, ) const shared: SharedOtel = { providers, diff --git a/src/types.ts b/src/types.ts index d8cc661..ebc309c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,6 +85,9 @@ export type SessionIdentity = { agentType: SessionAgentType parentID?: string startMs: number + tokens?: number + cost?: number + messages?: number } /** Model/agent metadata for an in-flight LLM step, keyed by assistant message ID. */ diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index e02746c..aaf47bb 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -105,6 +105,21 @@ describe("handleExecutionStarted", () => { expect(tracer.spans.at(-1)?.parentSpan).toBe(tracer.spans[0]) }) + test("preserves cumulative totals if the next execution ends before a usage update", () => { + const { ctx, histograms, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p" }), ctx) + handleUsageUpdated(evt("session.usage.updated", { sessionID: "ses_1", cost: 1.25, tokens: tokens(10, 5) }), ctx) + finalizeSession("ses_1", ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleExecutionEnded(evt("session.execution.failed", { sessionID: "ses_1", error: { type: "x", message: "y" } }), ctx, { + type: "failed", error: { type: "x", message: "y" }, + }) + finalizeSession("ses_1", ctx) + expect(histograms.sessionToken.calls.map((call) => call.value)).toEqual([15, 15]) + expect(histograms.sessionCost.calls.map((call) => call.value)).toEqual([1.25, 1.25]) + expect(tracer.spans.at(-1)?.attributes["session.total_tokens"]).toBe(15) + }) + test("retains a prompt queued during the current execution for the next run", () => { const { ctx, tracer } = makeCtx() handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p" }), ctx) diff --git a/tests/otel.test.ts b/tests/otel.test.ts index 9cd2fab..a4e37f9 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -4,6 +4,7 @@ import { OTLPLogExporter as OTLPHttpLogExporter } from "@opentelemetry/exporter- import { OTLPLogExporter as OTLPProtoLogExporter } from "@opentelemetry/exporter-logs-otlp-proto" import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc" import { OTLPMetricExporter as OTLPHttpMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http" +import { AggregationTemporality, InstrumentType } from "@opentelemetry/sdk-metrics" import { OTLPMetricExporter as OTLPProtoMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc" import { OTLPTraceExporter as OTLPHttpTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" @@ -128,6 +129,19 @@ describe("setupOtel", () => { expect(exporters.log).toBeInstanceOf(OTLPHttpLogExporter) expect(exporters.trace).toBeInstanceOf(OTLPHttpTraceExporter) }) + + test("uses the accepted metrics temporality rather than a rejected setup's environment", async () => { + const original = process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] + try { + process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = "cumulative" + providers = await setupOtel("http://collector:4318", "http/json", 60000, 5000, "2.0.0", undefined, undefined, "", "delta") + const exporter = exportersOf(providers).metric as OTLPHttpMetricExporter + expect(exporter.selectAggregationTemporality(InstrumentType.COUNTER)).toBe(AggregationTemporality.DELTA) + } finally { + if (original === undefined) delete process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] + else process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = original + } + }) }) describe("forceFlushOtel", () => { From 2671f9224dbd857efb9c14753198439a523841bd Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:33:30 -0400 Subject: [PATCH 07/13] fix(v2): isolate exporter headers and measure tool execution --- AGENTS.md | 2 +- src/config.ts | 7 ++--- src/handlers/tool.ts | 52 ++++++++++++++++++------------------ src/otel.ts | 32 +++++++++++++++++----- src/types.ts | 1 + tests/config.test.ts | 14 +++++----- tests/handlers/spans.test.ts | 2 ++ tests/handlers/tool.test.ts | 17 +++++++++--- tests/otel.test.ts | 34 +++++++++++++++++++++++ 9 files changed, 113 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54ad091..1e68306 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ src/ - **Session identity** — retain the agent, subagent parent, and creation time across executions, and hydrate missed `session.created` events with `ctx.session.get`. - **Multi-location configuration** — process-wide exporters require identical telemetry configuration; reject a conflicting setup and derive project attributes from the observed session, not the loading location. - **Shutdown** — providers are flushed (never shut down) on plugin cleanup and once per process on `beforeExit`. Shutting down the global OTel providers poisons them for the rest of the process. -- **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_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_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_ATTRIBUTES`. Never use bare `OTEL_*` names for plugin config. Headers are passed directly to exporters; `loadConfig` copies `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes. - **`OPENCODE_ENABLE_TELEMETRY`** — all OTel instrumentation is gated on this env var (or the `enabled` plugin option). The plugin always loads regardless. - **`OPENCODE_METRIC_PREFIX`** — defaults to `opencode.`; set to `claude_code.` for Claude Code dashboard compatibility. - **Plugin options** — read from `ctx.options` during `setup`. Precedence is option → `OPENCODE_*` env → default. Keep option keys 1:1 with `PluginConfig` field names. diff --git a/src/config.ts b/src/config.ts index 7dfd2a0..270e911 100644 --- a/src/config.ts +++ b/src/config.ts @@ -144,10 +144,8 @@ function expandDisabledTraces(values: string[]): Set { * variables. For every field a provided option wins over the environment * variable, which in turn wins over the built-in default. * - * Copies the resolved headers, resource attributes, and metrics temporality into - * `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_RESOURCE_ATTRIBUTES`, and - * `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` so the OTel SDK picks them - * up automatically when initialised. + * Copies resource attributes and metrics temporality into the corresponding + * OTel environment variables. OTLP headers are passed directly to exporters. */ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { const resolvedOptions = typeof options === "object" && options !== null ? options : {} @@ -177,7 +175,6 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { if (metricsTemporality) process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = metricsTemporality - if (otlpHeaders) process.env["OTEL_EXPORTER_OTLP_HEADERS"] = otlpHeaders if (resourceAttributes) process.env["OTEL_RESOURCE_ATTRIBUTES"] = resourceAttributes const optionMetrics = pickStringList(resolvedOptions.disabledMetrics) diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts index ce0289e..fe9a8c6 100644 --- a/src/handlers/tool.ts +++ b/src/handlers/tool.ts @@ -31,7 +31,7 @@ const SHELL_TOOL_RE = /(^|\.)(bash|shell)$/i type ToolContent = { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } -/** Records the tool name and start time, and starts a child tool span of the owning step. */ +/** Records the tool name for correlation with its execution and terminal events. */ export function handleToolInputStarted(e: EventOf<"session.tool.input.started">, ctx: HandlerContext) { const d = e.data setBoundedMap(ctx.tracing.toolMeta, d.id, { @@ -40,27 +40,6 @@ export function handleToolInputStarted(e: EventOf<"session.tool.input.started">, tool: d.name, startMs: e.created, }) - if (!isTraceEnabled("tool", ctx)) return - const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) - const span = ctx.tracer.startSpan( - `${ctx.tracePrefix}tool.${d.name}`, - { - startTime: e.created, - kind: SpanKind.INTERNAL, - attributes: { - [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, - [SESSION_ID]: d.sessionID, - [TOOL_ID]: d.id, - [TOOL_NAME]: d.name, - [AGENT_NAME]: agentName, - "agent.type": agentType, - ...ctx.commonAttrs, - }, - }, - resolveStepContext(d.sessionID, d.assistantMessageID, ctx), - ) - setBoundedMap(ctx.tracing.toolSpans, d.id, span) - setBoundedMap(ctx.tracing.toolSpanContexts, d.id, span.spanContext()) } /** Attaches tool input to the span and retains the command for terminal commit detection. */ @@ -69,9 +48,30 @@ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: Handler const meta = ctx.tracing.toolMeta.get(d.id) const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) const inputJson = safeJson(d.input) + if (meta && d.executed) { + setBoundedMap(ctx.tracing.toolMeta, d.id, { ...meta, startMs: e.created, executionStarted: true }) + } - const span = ctx.tracing.toolSpans.get(d.id) + const span = d.executed && meta && isTraceEnabled("tool", ctx) + ? ctx.tracer.startSpan( + `${ctx.tracePrefix}tool.${meta.tool}`, + { + startTime: e.created, + kind: SpanKind.INTERNAL, + attributes: { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, + [SESSION_ID]: d.sessionID, + [TOOL_ID]: d.id, + [TOOL_NAME]: meta.tool, + ...ctx.commonAttrs, + }, + }, + resolveStepContext(d.sessionID, d.assistantMessageID, ctx), + ) + : undefined if (span) { + setBoundedMap(ctx.tracing.toolSpans, d.id, span) + setBoundedMap(ctx.tracing.toolSpanContexts, d.id, span.spanContext()) span.setAttributes({ [TOOL_PARAMETERS]: inputJson, [INPUT_VALUE]: inputJson, @@ -82,7 +82,7 @@ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: Handler } if (meta && typeof d.input["command"] === "string") { - setBoundedMap(ctx.tracing.toolMeta, d.id, { ...meta, command: d.input["command"] }) + setBoundedMap(ctx.tracing.toolMeta, d.id, { ...ctx.tracing.toolMeta.get(d.id)!, command: d.input["command"] }) } } @@ -111,7 +111,7 @@ function finishTool( const meta = ctx.tracing.toolMeta.get(callID) ctx.tracing.toolMeta.delete(callID) const tool = meta?.tool ?? "unknown" - const start = meta?.startMs ?? endMs + const start = executed && meta?.executionStarted ? meta.startMs : endMs const durationMs = Math.max(0, endMs - start) const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) const sizeBytes = output ? Buffer.byteLength(output, "utf8") : 0 @@ -135,7 +135,7 @@ function finishTool( }) } - if (isMetricEnabled("tool.duration", ctx)) { + if (executed && isMetricEnabled("tool.duration", ctx)) { ctx.instruments.toolDurationHistogram.record(durationMs, { ...ctx.commonAttrs, "session.id": sessionID, diff --git a/src/otel.ts b/src/otel.ts index 456db7c..4963e62 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -65,6 +65,26 @@ export function buildHttpSignalUrl(endpoint: string, signal: "traces" | "metrics return url.toString() } +function withoutOtlpHeaderEnvironment(create: () => T): T { + const keys = [ + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + ] + const previous = keys.map((key) => process.env[key]) + for (const key of keys) delete process.env[key] + try { + return create() + } finally { + keys.forEach((key, index) => { + const value = previous[index] + if (value === undefined) delete process.env[key] + else process.env[key] = value + }) + } +} + function metricTemporalityPreference(value: MetricsTemporality | undefined): AggregationTemporalityPreference { if (value === "delta") return AggregationTemporalityPreference.DELTA if (value === "lowmemory") return AggregationTemporalityPreference.LOWMEMORY @@ -98,21 +118,21 @@ export async function setupOtel( console.warn("[opencode-plugin-otel] Failed to prewarm OTLP headers helper. Falling back to refresh-on-auth-failure.", error) } } - const makeMetricExporter = (headers: HeadersMap) => protocol === "http/protobuf" + const makeMetricExporter = (headers: HeadersMap) => withoutOtlpHeaderEnvironment(() => protocol === "http/protobuf" ? new OTLPProtoMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers, temporalityPreference }) : protocol === "http/json" ? new OTLPHttpMetricExporter({ url: buildHttpSignalUrl(endpoint, "metrics"), headers, temporalityPreference }) - : new OTLPMetricExporter({ url: endpoint, metadata: createGrpcMetadata(headers), temporalityPreference }) - const makeLogExporter = (headers: HeadersMap) => protocol === "http/protobuf" + : new OTLPMetricExporter({ url: endpoint, metadata: createGrpcMetadata(headers), temporalityPreference })) + const makeLogExporter = (headers: HeadersMap) => withoutOtlpHeaderEnvironment(() => protocol === "http/protobuf" ? new OTLPProtoLogExporter({ url: buildHttpSignalUrl(endpoint, "logs"), headers }) : protocol === "http/json" ? new OTLPHttpLogExporter({ url: buildHttpSignalUrl(endpoint, "logs"), headers }) - : new OTLPLogExporter({ url: endpoint, metadata: createGrpcMetadata(headers) }) - const makeTraceExporter = (headers: HeadersMap) => protocol === "http/protobuf" + : new OTLPLogExporter({ url: endpoint, metadata: createGrpcMetadata(headers) })) + const makeTraceExporter = (headers: HeadersMap) => withoutOtlpHeaderEnvironment(() => protocol === "http/protobuf" ? new OTLPProtoTraceExporter({ url: buildHttpSignalUrl(endpoint, "traces"), headers }) : protocol === "http/json" ? new OTLPHttpTraceExporter({ url: buildHttpSignalUrl(endpoint, "traces"), headers }) - : new OTLPTraceExporter({ url: endpoint, metadata: createGrpcMetadata(headers) }) + : new OTLPTraceExporter({ url: endpoint, metadata: createGrpcMetadata(headers) })) const metricExporter = otlpHeadersHelper ? new RefreshingMetricExporter(makeMetricExporter, dynamicHeaders) : makeMetricExporter(staticHeaders) diff --git a/src/types.ts b/src/types.ts index ebc309c..0cc3531 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,7 @@ export type ToolMeta = { assistantMessageID: string tool: string startMs: number + executionStarted?: boolean command?: string } diff --git a/tests/config.test.ts b/tests/config.test.ts index 5500896..d4084c7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -146,10 +146,10 @@ describe("loadConfig", () => { expect(cfg.logsInterval).toBe(5000) }) - test("copies OPENCODE_OTLP_HEADERS to OTEL_EXPORTER_OTLP_HEADERS", () => { + test("keeps OPENCODE_OTLP_HEADERS out of global OTel exporter configuration", () => { process.env["OPENCODE_OTLP_HEADERS"] = "api-key=abc123" - loadConfig() - expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("api-key=abc123") + expect(loadConfig().otlpHeaders).toBe("api-key=abc123") + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined() }) test("reads OPENCODE_OTLP_HEADERS_HELPER", () => { @@ -227,11 +227,11 @@ describe("loadConfig", () => { expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBe("existing=attr") }) - test("OPENCODE_OTLP_HEADERS overwrites pre-existing OTEL_EXPORTER_OTLP_HEADERS", () => { + test("OPENCODE_OTLP_HEADERS does not mutate pre-existing global OTel headers", () => { process.env["OTEL_EXPORTER_OTLP_HEADERS"] = "old-header=old" process.env["OPENCODE_OTLP_HEADERS"] = "new-header=new" loadConfig() - expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("new-header=new") + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("old-header=old") }) test("OPENCODE_RESOURCE_ATTRIBUTES overwrites pre-existing OTEL_RESOURCE_ATTRIBUTES", () => { @@ -423,10 +423,10 @@ describe("loadConfig options", () => { expect(loadConfig({ metricPrefix: "claude_code." }).metricPrefix).toBe("claude_code.") }) - test("option otlpHeaders is copied to OTEL_EXPORTER_OTLP_HEADERS", () => { + test("option otlpHeaders is resolved without global OTel header mutation", () => { const cfg = loadConfig({ otlpHeaders: "api-key=opt" }) expect(cfg.otlpHeaders).toBe("api-key=opt") - expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBe("api-key=opt") + expect(process.env["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined() }) test("option resourceAttributes is copied to OTEL_RESOURCE_ATTRIBUTES", () => { diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index ffba0cd..01ed4a7 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test" import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" import { handleStepStarted } from "../../src/handlers/step.ts" import { handleToolInputStarted } from "../../src/handlers/tool.ts" +import { handleToolCalled } from "../../src/handlers/tool.ts" import { makeCtx, evt } from "../helpers.ts" describe("trace nesting", () => { @@ -14,6 +15,7 @@ describe("trace nesting", () => { ctx, ) handleToolInputStarted(evt("session.tool.input.started", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", name: "read" }), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: true }), ctx) const run = tracer.spans.find((s) => s.name === "opencode.session")! const step = tracer.spans.find((s) => s.name === "opencode.llm")! diff --git a/tests/handlers/tool.test.ts b/tests/handlers/tool.test.ts index 7fa8dd8..f20b653 100644 --- a/tests/handlers/tool.test.ts +++ b/tests/handlers/tool.test.ts @@ -14,12 +14,11 @@ function inputStarted(id = "call_1", name = "bash", sessionID = "ses_1", created } describe("handleToolInputStarted", () => { - test("starts a tool span named after the tool", () => { + test("stores the tool name without counting model input streaming as execution", () => { const { ctx, tracer } = makeCtx() seed(ctx) handleToolInputStarted(inputStarted(), ctx) - const span = tracer.spans.find((s) => s.name.startsWith("opencode.tool."))! - expect(span.name).toBe("opencode.tool.bash") + expect(tracer.spans.filter((s) => s.name.startsWith("opencode.tool."))).toHaveLength(0) expect(ctx.tracing.toolMeta.get("call_1")!.tool).toBe("bash") }) }) @@ -64,6 +63,7 @@ describe("handleToolSuccess", () => { const { ctx, histograms, logger, tracer } = makeCtx() seed(ctx) handleToolInputStarted(inputStarted("call_1", "read", "ses_1", 1000), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: true }, 1000), ctx) handleToolSuccess(evt("session.tool.success", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", content: [{ type: "text", text: "file body" }], executed: true }, 1250), ctx) expect(histograms.tool.calls[0]!.value).toBe(250) expect(histograms.tool.calls[0]!.attrs["success"]).toBe(true) @@ -82,10 +82,21 @@ describe("handleToolFailed", () => { const { ctx, histograms, logger, tracer } = makeCtx() seed(ctx) handleToolInputStarted(inputStarted(), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: true }, 1000), ctx) handleToolFailed(evt("session.tool.failed", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", error: { type: "ToolError", message: "nope" }, executed: true }, 1200), ctx) expect(histograms.tool.calls[0]!.attrs["success"]).toBe(false) expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ToolError: nope") const span = tracer.spans.find((s) => s.name === "opencode.tool.bash")! expect(span.status.code).toBe(SpanStatusCode.ERROR) }) + + test("measures execution from called, excluding slow argument streaming", () => { + const { ctx, histograms, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted("call_1", "read", "ses_1", 1000), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", input: {}, executed: true }, 5000), ctx) + handleToolSuccess(evt("session.tool.success", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_1", content: [{ type: "text", text: "ok" }], executed: true }, 5300), ctx) + expect(histograms.tool.calls[0]!.value).toBe(300) + expect(tracer.spans.find((span) => span.name === "opencode.tool.read")?.startTime).toBe(5000) + }) }) diff --git a/tests/otel.test.ts b/tests/otel.test.ts index a4e37f9..7d7b305 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -142,6 +142,40 @@ describe("setupOtel", () => { else process.env["OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE"] = original } }) + + test("does not send inherited OTLP credentials when the accepted setup has no headers", async () => { + const previous = process.env["OTEL_EXPORTER_OTLP_HEADERS"] + const previousMetrics = process.env["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] + const received: Headers[] = [] + const server = Bun.serve({ + port: 0, + fetch(request) { + received.push(request.headers) + return new Response(null, { status: 200 }) + }, + }) + try { + process.env["OTEL_EXPORTER_OTLP_HEADERS"] = "Authorization=Bearer rejected" + process.env["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] = "x-rejected=secret" + providers = await setupOtel(`http://127.0.0.1:${server.port}`, "http/json", 60000, 5000, "2.0.0") + providers.meterProvider.getMeter("test").createCounter("test.counter").add(1) + await providers.meterProvider.forceFlush() + expect(received.length).toBeGreaterThan(0) + expect(received[0]!.get("authorization")).toBeNull() + expect(received[0]!.get("x-rejected")).toBeNull() + } finally { + const active = providers + providers = undefined + if (active) await Promise.allSettled([ + active.meterProvider.shutdown(), active.loggerProvider.shutdown(), active.tracerProvider.shutdown(), + ]) + if (previous === undefined) delete process.env["OTEL_EXPORTER_OTLP_HEADERS"] + else process.env["OTEL_EXPORTER_OTLP_HEADERS"] = previous + if (previousMetrics === undefined) delete process.env["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] + else process.env["OTEL_EXPORTER_OTLP_METRICS_HEADERS"] = previousMetrics + server.stop() + } + }) }) describe("forceFlushOtel", () => { From 7b9f6b7241b638922b1f0fd8a6807e38842a1bae Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:38:56 -0400 Subject: [PATCH 08/13] fix(v2): drain event subscriptions and persist selected agents --- src/handlers/session.ts | 10 ++++++++++ src/plugin.ts | 33 +++++++++++++++++---------------- src/state.ts | 1 + src/types.ts | 3 --- src/util.ts | 18 +++++++++++++++++- tests/handlers/session.test.ts | 12 ++++++++++++ tests/state.test.ts | 33 ++++++++++++++++++++++++++++++++- 7 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index f496c92..d225c5f 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -167,6 +167,14 @@ export function handleSessionCreated(e: EventOf<"session.created">, ctx: Handler void ctx.log("info", "otel: session.created", { sessionID: d.sessionID, isSubagent }) } +export function handleAgentSelected(e: EventOf<"session.agent.selected">, ctx: HandlerContext) { + const { sessionID, agent } = e.data + const identity = ctx.tracing.sessionIdentity.get(sessionID) + if (identity) setBoundedMap(ctx.tracing.sessionIdentity, sessionID, { ...identity, agent }) + const totals = ctx.tracing.sessionTotals.get(sessionID) + if (totals) setBoundedMap(ctx.tracing.sessionTotals, sessionID, { ...totals, agent }) +} + /** Starts the root run span for a single execution (user turn), keyed by session id. */ export function handleExecutionStarted(e: EventOf<"session.execution.started">, ctx: HandlerContext) { const sessionID = e.data.sessionID @@ -330,6 +338,8 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { if (identity) { setBoundedMap(ctx.tracing.sessionIdentity, sessionID, { ...identity, + agent: totals.agent, + agentType: totals.agentType, tokens: totals.tokens, cost: totals.cost, messages: totals.messages, diff --git a/src/plugin.ts b/src/plugin.ts index a2865e5..a5f4292 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,11 +6,12 @@ import { forceFlushOtel } from "./otel.ts" import { remoteParentContext } from "./trace-context.ts" import { acquireSharedOtel, acquireTracingState, createFlushScheduler, flushSharedOtel, releaseSharedOtel } from "./state.ts" import { LEVELS, type HandlerContext, type Level, type OpenCodeContext, type OpenCodeEvent } from "./types.ts" -import { contextForSession, enqueueEvent, setBoundedMap } from "./util.ts" +import { consumeEvents, contextForSession, setBoundedMap } from "./util.ts" import { finalizeSession, handleExecutionEnded, handleExecutionStarted, + handleAgentSelected, handlePromptEnqueued, handleSessionCreated, handleSessionIdle, @@ -144,6 +145,9 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> case "session.created": handleSessionCreated(event, eventCtx) break + case "session.agent.selected": + handleAgentSelected(event, eventCtx) + break case "session.inbox.enqueued": handlePromptEnqueued(event, eventCtx, config.capturePromptInLogs) break @@ -214,26 +218,23 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> } const controller = new AbortController() - const running = (async () => { - try { - for await (const event of ctx.event.subscribe({ signal: controller.signal })) { - try { - await enqueueEvent(tracing, event.id, () => dispatch(event)) - } catch (err) { - await log("error", "otel: failed to handle event", { - type: event.type, - error: err instanceof Error ? err.message : String(err), - }) - } - } - } catch (err) { + const running = consumeEvents( + ctx.event.subscribe({ signal: controller.signal }), + tracing, + dispatch, + async (event, err) => { + await log("error", "otel: failed to handle event", { + type: event.type, + error: err instanceof Error ? err.message : String(err), + }) + }, + ).catch(async (err) => { if (!controller.signal.aborted) { await log("error", "otel: event subscription ended", { error: err instanceof Error ? err.message : String(err), }) } - } - })() + }) return async () => { controller.abort() diff --git a/src/state.ts b/src/state.ts index 3380db5..0394d16 100644 --- a/src/state.ts +++ b/src/state.ts @@ -26,6 +26,7 @@ export function configKey(config: PluginConfig): string { return createHash("sha256").update(normalized).digest("hex") } +/** Schedules exporter flushes outside event dispatch and drains them during cleanup. */ export function createFlushScheduler(flush: () => Promise) { let pending = Promise.resolve() return { diff --git a/src/types.ts b/src/types.ts index 0cc3531..d2ffe0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,9 +12,6 @@ export type Level = keyof typeof LEVELS /** Maximum number of entries kept in bounded correlation maps. */ export const MAX_PENDING = 500 -/** Maximum number of event ids retained for cross-instance de-duplication. */ -export const MAX_SEEN_EVENTS = 10_000 - /** The OpenCode V2 plugin context passed to `setup`. */ export type OpenCodeContext = Plugin.Context diff --git a/src/util.ts b/src/util.ts index bbcd147..d8351b2 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,7 @@ import { trace, type Context } from "@opentelemetry/api" -import { MAX_PENDING, MAX_SEEN_EVENTS, type HandlerContext, type SessionAgentType, type TracingState } from "./types.ts" +import { MAX_PENDING, type HandlerContext, type SessionAgentType, type TracingState } from "./types.ts" + +const MAX_SEEN_EVENTS = 10_000 const GEN_AI_PROVIDER_NAMES: Readonly> = { "amazon-bedrock": "aws.bedrock", @@ -79,6 +81,20 @@ export function enqueueEvent(state: TracingState, id: string, handle: () => Prom return task } +/** Enqueues subscription events without blocking the reader while preserving shared dispatch order. */ +export async function consumeEvents( + events: AsyncIterable, + state: TracingState, + dispatch: (event: T) => Promise, + onError: (event: T, error: unknown) => Promise, +): Promise { + let last = Promise.resolve() + for await (const event of events) { + last = enqueueEvent(state, event.id, () => dispatch(event)).catch((error) => onError(event, error)) + } + await last +} + /** * Returns `true` if the metric name (without prefix) is not in the disabled set. * The `name` should be the suffix after the metric prefix, e.g. `"session.count"`. diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index aaf47bb..e49e046 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -4,6 +4,7 @@ import { finalizeSession, handleExecutionEnded, handleExecutionStarted, + handleAgentSelected, handlePromptEnqueued, handleSessionCreated, handleSessionIdle, @@ -105,6 +106,17 @@ describe("handleExecutionStarted", () => { expect(tracer.spans.at(-1)?.parentSpan).toBe(tracer.spans[0]) }) + test("retains an agent selection through finalization and the next execution", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p", agent: "build" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + handleAgentSelected(evt("session.agent.selected", { sessionID: "ses_1", agent: "explore" }), ctx) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "ses_1" }), ctx, { type: "succeeded" }) + finalizeSession("ses_1", ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "ses_1" }), ctx) + expect(tracer.spans.at(-1)?.attributes["agent.name"]).toBe("explore") + }) + test("preserves cumulative totals if the next execution ends before a usage update", () => { const { ctx, histograms, tracer } = makeCtx() handleSessionCreated(evt("session.created", { sessionID: "ses_1", projectID: "p" }), ctx) diff --git a/tests/state.test.ts b/tests/state.test.ts index 3800cbc..c38de8e 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -2,9 +2,10 @@ import { describe, test, expect } from "bun:test" import { loadConfig } from "../src/config.ts" import { acquireSharedOtel, configKey, createFlushScheduler } from "../src/state.ts" import { makeCtx } from "./helpers.ts" -import { contextForSession, enqueueEvent, markSeen } from "../src/util.ts" +import { consumeEvents, contextForSession, enqueueEvent, markSeen } from "../src/util.ts" import type { HandlerContext } from "../src/types.ts" import { handleExecutionStarted } from "../src/handlers/session.ts" +import { handleModelRequest } from "../src/handlers/chat-headers.ts" import { evt } from "./helpers.ts" describe("multi-location telemetry", () => { @@ -69,6 +70,36 @@ describe("multi-location telemetry", () => { expect(order).toEqual(["start", "end"]) }) + test("drains buffered step events before a concurrent model request uses their context", async () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("*") + ctx.tracing.activeLlm.set("s", { + agent: "build", modelID: "m", providerID: "vllm", + spanContext: { traceId: "0af7651916cd43dd8448eb211c80319c", spanId: "b7ad6b7169203331", traceFlags: 1 }, + }) + let release!: () => void + let queued!: () => void + const lookup = new Promise((resolve) => { release = resolve }) + const bothQueued = new Promise((resolve) => { queued = resolve }) + async function* events() { + yield { id: "step.started" } + yield { id: "step.ended" } + queued() + } + const processing = consumeEvents(events(), ctx.tracing, async (event) => { + if (event.id === "step.started") await lookup + else ctx.tracing.activeLlm.delete("s") + }, async () => {}) + await bothQueued + const headers: Record = {} + const request = handleModelRequest({ sessionID: "s", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers }, ctx) + expect(headers["traceparent"]).toBeUndefined() + release() + await Promise.all([processing, request]) + expect(headers["traceparent"]).toBeDefined() + expect(headers["traceparent"]).not.toContain("b7ad6b7169203331") + }) + test("does not block the shared event queue on a pending exporter flush", async () => { const { ctx } = makeCtx() let release!: () => void From 78377ce5c27df4bc680823ddafc68307592e946a Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:41:58 -0400 Subject: [PATCH 09/13] fix(v2): drain failed subscriptions and restore API latency --- src/handlers/step.ts | 7 +++++++ src/types.ts | 1 + src/util.ts | 9 ++++++--- tests/handlers/step.test.ts | 14 ++++++++++++++ tests/state.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/handlers/step.ts b/src/handlers/step.ts index ac0d7dd..93121a2 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -73,6 +73,7 @@ export function handleStepStarted(e: EventOf<"session.step.started">, ctx: Handl const agentType = totals.agentType setBoundedMap(ctx.tracing.stepMeta, d.assistantMessageID, { sessionID: d.sessionID, + startMs: d.started ?? e.created, agent: d.agent, agentType, modelID: d.model.id, @@ -164,6 +165,7 @@ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerCo const agentType = meta?.agentType ?? sessionTotals?.agentType ?? "unknown" const modelID = meta?.modelID ?? "unknown" const providerID = meta?.providerID ?? "unknown" + const durationMs = Math.max(0, e.created - (meta?.startMs ?? e.created)) recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost, ctx) const firstStep = countMessage(d.assistantMessageID, d.sessionID, modelID, providerID, agent, ctx) @@ -184,6 +186,7 @@ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerCo [LLM_FINISH_REASON]: d.finish, [LLM_COST_TOTAL]: d.cost, cost_usd: d.cost, + duration_ms: durationMs, ...(output ? { [OUTPUT_VALUE]: output, [OUTPUT_MIME_TYPE]: MimeType.TEXT, @@ -209,6 +212,7 @@ export function handleStepEnded(e: EventOf<"session.step.ended">, ctx: HandlerCo "gen_ai.provider.name": genAiProviderName(providerID), ...agentAttrs(agent, agentType), cost_usd: d.cost, + duration_ms: durationMs, input_tokens: d.tokens.input, output_tokens: d.tokens.output, reasoning_tokens: d.tokens.reasoning, @@ -229,6 +233,7 @@ export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: Handler const modelID = meta?.modelID ?? "unknown" const providerID = meta?.providerID ?? "unknown" const error = errorSummary(d.error) + const durationMs = Math.max(0, e.created - (meta?.startMs ?? e.created)) recordUsageMetrics(d.sessionID, modelID, agent, d.tokens, d.cost, ctx) const firstStep = countMessage(d.assistantMessageID, d.sessionID, modelID, providerID, agent, ctx) @@ -241,6 +246,7 @@ export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: Handler [AGENT_NAME]: agent, "agent.type": agentType, [LLM_FINISH_REASON]: d.finish ?? "error", + duration_ms: durationMs, ...(output ? { [OUTPUT_VALUE]: output, [OUTPUT_MIME_TYPE]: MimeType.TEXT } : {}), }) span.setStatus({ code: SpanStatusCode.ERROR, message: error }) @@ -262,6 +268,7 @@ export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: Handler "gen_ai.provider.name": genAiProviderName(providerID), ...agentAttrs(agent, agentType), error, + duration_ms: durationMs, ...ctx.commonAttrs, }, }) diff --git a/src/types.ts b/src/types.ts index d2ffe0d..0a2c166 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,6 +90,7 @@ export type SessionIdentity = { /** Model/agent metadata for an in-flight LLM step, keyed by assistant message ID. */ export type StepMeta = { sessionID: string + startMs: number agent: string agentType: SessionAgentType | "unknown" modelID: string diff --git a/src/util.ts b/src/util.ts index d8351b2..2b79d32 100644 --- a/src/util.ts +++ b/src/util.ts @@ -89,10 +89,13 @@ export async function consumeEvents( onError: (event: T, error: unknown) => Promise, ): Promise { let last = Promise.resolve() - for await (const event of events) { - last = enqueueEvent(state, event.id, () => dispatch(event)).catch((error) => onError(event, error)) + try { + for await (const event of events) { + last = enqueueEvent(state, event.id, () => dispatch(event)).catch((error) => onError(event, error)) + } + } finally { + await last } - await last } /** diff --git a/tests/handlers/step.test.ts b/tests/handlers/step.test.ts index e506e34..a2d305c 100644 --- a/tests/handlers/step.test.ts +++ b/tests/handlers/step.test.ts @@ -94,3 +94,17 @@ describe("handleStepFailed", () => { expect(counters.cost.calls.map((call) => call.value)).toEqual([2]) }) }) + +describe("API log durations", () => { + test("records request and error latency without LLM traces", () => { + const { ctx, logger, tracer } = makeCtx("proj_test", [], ["llm"]) + seedSession(ctx) + handleStepStarted(stepStarted("ses_1", "msg_1"), ctx) + handleStepEnded(evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "stop", cost: 0.1, tokens: tokens() }, 2000), ctx) + handleStepStarted(stepStarted("ses_1", "msg_2"), ctx) + handleStepFailed(evt("session.step.failed", { sessionID: "ses_1", assistantMessageID: "msg_2", error: { type: "x", message: "y" } }, 2400), ctx) + expect(logger.records.find((record) => record.body === "api_request")?.attributes?.["duration_ms"]).toBe(500) + expect(logger.records.find((record) => record.body === "api_error")?.attributes?.["duration_ms"]).toBe(900) + expect(tracer.spans.some((span) => span.name === "opencode.llm")).toBe(false) + }) +}) diff --git a/tests/state.test.ts b/tests/state.test.ts index c38de8e..b1e7514 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -100,6 +100,29 @@ describe("multi-location telemetry", () => { expect(headers["traceparent"]).not.toContain("b7ad6b7169203331") }) + test("drains a queued handler even when the subscription iterator throws", async () => { + const { ctx } = makeCtx() + let release!: () => void + let started!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const entered = new Promise((resolve) => { started = resolve }) + async function* events() { + yield { id: "step.started" } + throw new Error("stream failed") + } + let finished = false + const running = consumeEvents(events(), ctx.tracing, async () => { + started() + await gate + finished = true + }, async () => {}) + await entered + expect(finished).toBe(false) + release() + await expect(running).rejects.toThrow("stream failed") + expect(finished).toBe(true) + }) + test("does not block the shared event queue on a pending exporter flush", async () => { const { ctx } = makeCtx() let release!: () => void From 9188e4201e43c98961e570d9a7fcc217edcb2cb8 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 17:51:23 -0400 Subject: [PATCH 10/13] chore: remove unused CodeRabbit configuration --- .coderabbit.yaml | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index d25fcb3..0000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json -language: en-US - -reviews: - profile: chill - high_level_summary: true - review_status: true - poem: false - auto_review: - enabled: true - drafts: false - path_filters: - - "!**/*.lock" - - "!dist/**" - - "!coverage/**" - - "!**/*.snap" - pre_merge_checks: - docstrings: - mode: warning - threshold: 80 - description: - mode: warning - issue_assessment: - mode: warning - -chat: - auto_reply: true From 0947c6578f439812c1940113df87454618ea5c97 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 18:41:00 -0400 Subject: [PATCH 11/13] feat(v2): capture model context and correlate subagent traces Add opt-in bounded text previews from the primary context hook, inject trace context through experimental WebSocket handshakes for configured providers, and nest correlated child runs under subagent dispatch spans using V2 progress/result metadata. Preserve the parent-run fallback when correlation is ambiguous. --- AGENTS.md | 6 ++-- CONTRIBUTING.md | 4 +-- README.md | 26 +++++++++++++++ src/config.ts | 7 ++-- src/handlers/chat-headers.ts | 61 +++++++++++++++++++++++++++++++--- src/handlers/session.ts | 6 ++-- src/handlers/step.ts | 6 ++++ src/handlers/tool.ts | 63 +++++++++++++++++++++++++++++++++--- src/plugin.ts | 23 +++++++++++-- src/state.ts | 3 ++ src/types.ts | 14 ++++++++ src/util.ts | 15 +++++++++ tests/chat-headers.test.ts | 40 ++++++++++++++++++++++- tests/config.test.ts | 9 ++++++ tests/handlers/spans.test.ts | 49 ++++++++++++++++++++++++++++ tests/handlers/tool.test.ts | 10 ++++++ tests/helpers.ts | 3 ++ 17 files changed, 325 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1e68306..916519f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,9 @@ src/ └── handlers/ ├── session.ts — session.created, session.inbox.enqueued, session.execution.*, retry/idle/usage ├── step.ts — session.step.*, session.text.ended (LLM spans + token/cost/cache metrics) - ├── tool.ts — session.tool.* (tool spans, duration, commit detection) + ├── tool.ts — session.tool.* (tool spans, subagent correlation, duration, commit detection) ├── permission.ts — permission.asked/replied - └── chat-headers.ts — session.hook("model.request") trace propagation + └── chat-headers.ts — context preview and model.request / WebSocket trace propagation ``` ## Key conventions @@ -57,6 +57,8 @@ src/ - **Single source of truth for tokens/cost** — token and cost counters are incremented once per `session.step.ended`/`failed`; session totals come from `session.usage.updated` (cumulative) with a per-step fallback. - **Event de-duplication** — V2 may deliver the same event to multiple plugin instances; serialize shared dispatch before deduping by `event.id` via `markSeen` so later events cannot overtake an asynchronous session lookup. - **Session identity** — retain the agent, subagent parent, and creation time across executions, and hydrate missed `session.created` events with `ctx.session.get`. +- **Subagent correlation** — only parent a child run under a dispatch tool span when a child session ID in tool progress/result metadata or one unambiguous live candidate identifies it; otherwise use the parent run. +- **Model context capture** — opt-in only, text parts only, bounded; never serialize full media or structured tool payloads into span attributes. - **Multi-location configuration** — process-wide exporters require identical telemetry configuration; reject a conflicting setup and derive project attributes from the observed session, not the loading location. - **Shutdown** — providers are flushed (never shut down) on plugin cleanup and once per process on `beforeExit`. Shutting down the global OTel providers poisons them for the rest of the process. - **All env vars are `OPENCODE_` prefixed** — `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_CAPTURE_PROMPT_IN_LOGS`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`, `OPENCODE_SPAN_ATTRIBUTES`. Never use bare `OTEL_*` names for plugin config. Headers are passed directly to exporters; `loadConfig` copies `OPENCODE_RESOURCE_ATTRIBUTES` → `OTEL_RESOURCE_ATTRIBUTES` before the SDK initializes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4e9cfb..744ef8c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,9 +53,9 @@ src/ └── handlers/ ├── session.ts — session.created / session.execution.* / session.status ├── step.ts — session.step.* (LLM spans + token/cost metrics) - ├── tool.ts — session.tool.* (tool spans, duration, commits) + ├── tool.ts — session.tool.* (tool spans, subagents, duration, commits) ├── permission.ts — permission.asked / permission.replied - └── chat-headers.ts — model.request trace propagation + └── chat-headers.ts — context preview and model.request / WebSocket propagation ``` ## Testing locally with a collector diff --git a/README.md b/README.md index c9b799e..5b87b73 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,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) + - [Model-visible context capture](#model-visible-context-capture) - [LLM trace propagation](#llm-trace-propagation) - [Disabling specific metrics](#disabling-specific-metrics) - [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs) @@ -58,6 +59,10 @@ replaced by successful shell-tool completion. Message/part spans are replaced by per-step LLM spans; completed text segments still populate `output.value` and `llm.output_messages`. +When V2 provides an unambiguous child session ID through subagent tool progress or result metadata, +subagent run spans nest under the dispatch tool span. Ambiguous or missed correlations fall back to +the parent run span; a background child may outlive its already-ended dispatch tool span. + ## What it instruments ### Metrics @@ -139,6 +144,7 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `OPENCODE_DISABLE_METRICS` | *(unset)* | Comma-separated list of metric name suffixes to disable (e.g. `cache.count,session.duration`) | | `OPENCODE_DISABLE_LOGS` | *(unset)* | Set to any non-empty value to suppress all OTLP log events while leaving metrics and traces unchanged | | `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | *(unset)* | Set to any non-empty value to include the full prompt text in the `prompt` attribute of `user_prompt` log events. **Log events only** — trace spans carry an observed prompt in `input.value` regardless of this flag (disable span-level capture separately via `OPENCODE_DISABLE_TRACES`). **Off by default — prompts may contain secrets or PII; enable only for trusted collectors.** | +| `OPENCODE_CAPTURE_MODEL_CONTEXT` | *(unset)* | Set to any non-empty value to attach a bounded text-only preview of the primary model-visible context to LLM spans. Off by default; can include system instructions, earlier messages, and tool text. | | `OPENCODE_DISABLE_TRACES` | *(unset)* | Comma-separated list of trace types to disable (`session`, `llm`, `tool`). Use `all`, `*`, `true`, or `1` to disable every trace type | | `OPENCODE_OTLP_HEADERS` | *(unset)* | Comma-separated `key=value` headers added to all OTLP exports. **Keep out of version control — may contain sensitive auth tokens.** | | `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`. | @@ -181,6 +187,7 @@ Option keys mirror the resolved config and map to the environment variables: | `enabled` | `OPENCODE_ENABLE_TELEMETRY` | | `logsEnabled` | `OPENCODE_DISABLE_LOGS` (inverted) | | `capturePromptInLogs` | `OPENCODE_CAPTURE_PROMPT_IN_LOGS` | +| `captureModelContext` | `OPENCODE_CAPTURE_MODEL_CONTEXT` | | `logLevel` | *(none — option only)*: `debug`, `info`, `warn`, `error` | | `endpoint` | `OPENCODE_OTLP_ENDPOINT` | | `protocol` | `OPENCODE_OTLP_PROTOCOL` | @@ -258,6 +265,20 @@ 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. +### Model-visible context capture + +```bash +export OPENCODE_CAPTURE_MODEL_CONTEXT=1 +``` + +When enabled, `session.hook("context")` captures up to two system parts and the last twelve +messages, retaining only text parts and truncating each to 1,000 characters. The matching primary +LLM span receives this preview in `llm.input_messages` and its latest user text in `input.value`. +Media bytes and structured tool payloads are excluded. The hook observes context at its position +in plugin order; subsequent plugins can still change the request. This is independent of +`OPENCODE_CAPTURE_PROMPT_IN_LOGS` and can include secrets or PII from previous messages, +system instructions, or tool text. Use it only with a trusted collector. + ### LLM trace propagation Use `OPENCODE_TRACE_PROPAGATION_PROVIDERS` to connect this plugin's LLM spans to spans emitted by an LLM gateway such as LiteLLM or vLLM. For matching provider IDs, the plugin injects the current `opencode.llm` span as the W3C `traceparent` header and includes `tracestate` when present. @@ -272,6 +293,11 @@ The values are opencode provider IDs, including custom names configured under th Only W3C trace context is propagated. The plugin does not inject arbitrary headers or W3C baggage. Configure static provider-specific headers through the provider's native `options.headers` setting in `opencode.json`. +For WebSocket-backed providers, the plugin also injects into V2's experimental +`experimental.ws.handshake` hook. Changing trace headers per step can reopen a reused socket, +so enable propagation only for providers where connected traces outweigh connection reuse. +The hook is experimental and should be verified against your provider's WebSocket route. + ### 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 270e911..887865f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ export type PluginConfig = { enabled: boolean logsEnabled: boolean capturePromptInLogs: boolean + captureModelContext: boolean logLevel: string | undefined endpoint: string protocol: "grpc" | "http/protobuf" | "http/json" @@ -49,8 +50,8 @@ export function parseAttributePairs(raw: string | undefined): Record } +type ModelVisibleContextEvent = { + sessionID: string + agent: string + model: { providerID: string; id: string } + system: readonly unknown[] + messages: readonly { role: string; content: readonly unknown[] }[] +} + +function textParts(parts: readonly unknown[]): string { + return parts + .filter((part): part is { type: "text"; text: string } => + typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") + .map((part) => part.text) + .join("\n") + .slice(0, 1_000) +} + +/** Stores a bounded text-only preview of the primary model-visible request. */ +export function captureModelContext(event: ModelVisibleContextEvent, ctx: HandlerContext): void { + const entries = [ + ...event.system.slice(-2).map((part) => ({ role: "system", content: textParts([part]) })), + ...event.messages.slice(-12).map((message) => ({ role: message.role, content: textParts(message.content) })), + ] + const latestUser = entries.findLast((entry) => entry.role === "user" && entry.content) + setBoundedMap(ctx.tracing.modelContexts, event.sessionID, { + agent: event.agent, + providerID: event.model.providerID, + modelID: event.model.id, + inputMessages: JSON.stringify(entries), + ...(latestUser ? { inputValue: latestUser.content } : {}), + }) +} + +/** Applies a matching captured request preview to its LLM span. */ +export function applyModelContext( + sessionID: string, + agent: string, + model: { providerID: string; id: string }, + span: Span, + ctx: HandlerContext, +): void { + const snapshot = ctx.tracing.modelContexts.get(sessionID) + if (!snapshot || snapshot.agent !== agent || snapshot.providerID !== model.providerID || snapshot.modelID !== model.id) return + span.setAttributes({ + [LLM_INPUT_MESSAGES]: snapshot.inputMessages, + ...(snapshot.inputValue ? { [INPUT_VALUE]: snapshot.inputValue, [INPUT_MIME_TYPE]: MimeType.TEXT } : {}), + }) + ctx.tracing.modelContexts.delete(sessionID) +} + /** Injects the active LLM span's W3C trace context into outbound model requests. */ export async function handleModelRequest(event: ModelRequestEvent, ctx: HandlerContext): Promise { if (event.kind !== "primary") return const providerID = event.model.providerID - if (!ctx.tracePropagationProviders.has(providerID) && !ctx.tracePropagationProviders.has("*")) return + const propagate = ctx.tracePropagationProviders.has(providerID) || ctx.tracePropagationProviders.has("*") + if (!propagate && !ctx.tracing.modelContexts.has(event.sessionID)) return if (!isTraceEnabled("llm", ctx)) return await ctx.tracing.eventQueue let active = ctx.tracing.activeLlm.get(event.sessionID) @@ -44,5 +95,7 @@ export async function handleModelRequest(event: ModelRequestEvent, ctx: HandlerC setBoundedMap(ctx.tracing.activeLlm, event.sessionID, active) } if (active.providerID !== providerID || active.modelID !== event.model.id || active.agent !== event.agent) return - injectTraceContext(active.spanContext, event.headers) + const span = ctx.tracing.provisionalLlm.get(event.sessionID) ?? ctx.tracing.activeStepSpans.get(event.sessionID) + if (span) applyModelContext(event.sessionID, event.agent, event.model, span, ctx) + if (propagate) injectTraceContext(active.spanContext, event.headers) } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index d225c5f..60ec6ac 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -18,7 +18,7 @@ import { isTraceEnabled, markSeen, modelRef, - resolveRunContext, + resolveSubagentTraceContext, setBoundedMap, totalTokens, } from "../util.ts" @@ -189,7 +189,7 @@ export function handleExecutionStarted(e: EventOf<"session.execution.started">, const isSubagent = totals?.agentType === "subagent" const parentCtx = isSubagent && totals?.parentID - ? resolveRunContext(totals.parentID, ctx) + ? resolveSubagentTraceContext(sessionID, totals.parentID, totals.agent, ctx) : ctx.rootContext() const promptText = pendingPrompt?.text ?? "" @@ -347,6 +347,8 @@ export function finalizeSession(sessionID: string, ctx: HandlerContext) { } ctx.tracing.sessionTotals.delete(sessionID) ctx.tracing.activeLlm.delete(sessionID) + ctx.tracing.activeStepSpans.delete(sessionID) + ctx.tracing.modelContexts.delete(sessionID) ctx.tracing.activePrompts.delete(sessionID) ctx.tracing.activeExecutions.delete(sessionID) diff --git a/src/handlers/step.ts b/src/handlers/step.ts index 93121a2..0978fa0 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -25,6 +25,7 @@ import { } from "@arizeai/openinference-semantic-conventions" import type { EventOf, HandlerContext, SessionTotals } from "../types.ts" import { ensureSession } from "./session.ts" +import { applyModelContext } from "./chat-headers.ts" import { agentAttrs, errorSummary, @@ -121,7 +122,9 @@ export function handleStepStarted(e: EventOf<"session.step.started">, ctx: Handl resolveRunContext(d.sessionID, ctx), ) if (matches) span.setAttributes(attributes) + applyModelContext(d.sessionID, d.agent, d.model, span, ctx) setBoundedMap(ctx.tracing.stepSpans, d.assistantMessageID, span) + setBoundedMap(ctx.tracing.activeStepSpans, d.sessionID, span) setBoundedMap(ctx.tracing.stepSpanContexts, d.assistantMessageID, span.spanContext()) setBoundedMap(ctx.tracing.activeLlm, d.sessionID, { agent: d.agent, @@ -309,6 +312,9 @@ function recordUsageMetrics( function cleanupStep(assistantMessageID: string, sessionID: string, ctx: HandlerContext) { const spanContext = ctx.tracing.stepSpanContexts.get(assistantMessageID) + if (spanContext && ctx.tracing.activeStepSpans.get(sessionID)?.spanContext().spanId === spanContext.spanId) { + ctx.tracing.activeStepSpans.delete(sessionID) + } ctx.tracing.stepSpans.delete(assistantMessageID) ctx.tracing.stepSpanContexts.delete(assistantMessageID) ctx.tracing.stepMeta.delete(assistantMessageID) diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts index fe9a8c6..077e2d4 100644 --- a/src/handlers/tool.ts +++ b/src/handlers/tool.ts @@ -46,13 +46,20 @@ export function handleToolInputStarted(e: EventOf<"session.tool.input.started">, export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: HandlerContext) { const d = e.data const meta = ctx.tracing.toolMeta.get(d.id) + const isSubagent = meta?.tool === "subagent" + const runs = d.executed const { agentName, agentType } = getSessionAgentMeta(d.sessionID, ctx) const inputJson = safeJson(d.input) - if (meta && d.executed) { - setBoundedMap(ctx.tracing.toolMeta, d.id, { ...meta, startMs: e.created, executionStarted: true }) + if (meta && (runs || isSubagent)) { + setBoundedMap(ctx.tracing.toolMeta, d.id, { + ...meta, + startMs: e.created, + executionStarted: runs, + ...(isSubagent && typeof d.input["agent"] === "string" ? { agent: d.input["agent"] } : {}), + }) } - const span = d.executed && meta && isTraceEnabled("tool", ctx) + const span = runs && meta && isTraceEnabled("tool", ctx) ? ctx.tracer.startSpan( `${ctx.tracePrefix}tool.${meta.tool}`, { @@ -86,14 +93,59 @@ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: Handler } } +function startSubagentSpan(callID: string, ctx: HandlerContext) { + const meta = ctx.tracing.toolMeta.get(callID) + if (!meta || meta.tool !== "subagent") return + setBoundedMap(ctx.tracing.toolMeta, callID, { ...meta, executionStarted: true }) + if (ctx.tracing.toolSpans.has(callID) || !isTraceEnabled("tool", ctx)) return + const { agentName, agentType } = getSessionAgentMeta(meta.sessionID, ctx) + const span = ctx.tracer.startSpan( + `${ctx.tracePrefix}tool.subagent`, + { + startTime: meta.startMs, + kind: SpanKind.INTERNAL, + attributes: { + [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.TOOL, + [SESSION_ID]: meta.sessionID, + [TOOL_ID]: callID, + [TOOL_NAME]: "subagent", + "subagent.agent": meta.agent ?? "unknown", + [AGENT_NAME]: agentName, + "agent.type": agentType, + ...ctx.commonAttrs, + }, + }, + resolveStepContext(meta.sessionID, meta.assistantMessageID, ctx), + ) + setBoundedMap(ctx.tracing.toolSpans, callID, span) + setBoundedMap(ctx.tracing.toolSpanContexts, callID, span.spanContext()) +} + +function linkSubagent(callID: string, childID: unknown, ctx: HandlerContext) { + const meta = ctx.tracing.toolMeta.get(callID) + if (typeof childID !== "string" || meta?.tool !== "subagent") return + setBoundedMap(ctx.tracing.toolMeta, callID, { ...meta, childSessionID: childID }) + startSubagentSpan(callID, ctx) + const span = ctx.tracing.toolSpans.get(callID) + span?.setAttribute("subagent.session_id", childID) + const spanContext = span?.spanContext() + if (spanContext) setBoundedMap(ctx.tracing.subagentParents, childID, spanContext) +} + +export function handleToolProgress(e: EventOf<"session.tool.progress">, ctx: HandlerContext) { + linkSubagent(e.data.id, e.data.metadata["sessionID"], ctx) +} + /** Ends a successful tool call: records duration, sets output attributes, and emits `tool_result`. */ export function handleToolSuccess(e: EventOf<"session.tool.success">, ctx: HandlerContext) { + linkSubagent(e.data.id, e.data.metadata?.["sessionID"], ctx) const output = contentText(e.data.content) finishTool(e.data.id, e.data.sessionID, e.created, true, e.data.executed, output, undefined, ctx) } /** Ends a failed tool call: records duration, sets error attributes, and emits `tool_result`. */ export function handleToolFailed(e: EventOf<"session.tool.failed">, ctx: HandlerContext) { + linkSubagent(e.data.id, e.data.metadata?.["sessionID"], ctx) const output = contentText(e.data.content) finishTool(e.data.id, e.data.sessionID, e.created, false, e.data.executed, output, errorSummary(e.data.error), ctx) } @@ -111,7 +163,8 @@ function finishTool( const meta = ctx.tracing.toolMeta.get(callID) ctx.tracing.toolMeta.delete(callID) const tool = meta?.tool ?? "unknown" - const start = executed && meta?.executionStarted ? meta.startMs : endMs + const observedExecution = executed || (tool === "subagent" && meta?.executionStarted === true) + const start = observedExecution && meta?.executionStarted ? meta.startMs : endMs const durationMs = Math.max(0, endMs - start) const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) const sizeBytes = output ? Buffer.byteLength(output, "utf8") : 0 @@ -135,7 +188,7 @@ function finishTool( }) } - if (executed && isMetricEnabled("tool.duration", ctx)) { + if (observedExecution && isMetricEnabled("tool.duration", ctx)) { ctx.instruments.toolDurationHistogram.record(durationMs, { ...ctx.commonAttrs, "session.id": sessionID, diff --git a/src/plugin.ts b/src/plugin.ts index a5f4292..a416d61 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -20,9 +20,9 @@ import { handleUsageUpdated, } from "./handlers/session.ts" import { handleStepEnded, handleStepFailed, handleStepStarted, handleTextEnded } from "./handlers/step.ts" -import { handleToolCalled, handleToolFailed, handleToolInputStarted, handleToolSuccess } from "./handlers/tool.ts" +import { handleToolCalled, handleToolFailed, handleToolInputStarted, handleToolProgress, handleToolSuccess } from "./handlers/tool.ts" import { handlePermissionAsked, handlePermissionReplied } from "./handlers/permission.ts" -import { handleModelRequest } from "./handlers/chat-headers.ts" +import { captureModelContext, handleModelRequest } from "./handlers/chat-headers.ts" const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown" const EXIT_HOOK_KEY = "__opencode_plugin_otel_exit_hook__" @@ -123,10 +123,26 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> async (id) => ctx.session.get({ sessionID: id }), ) + if (config.captureModelContext) { + await ctx.session.hook("context", async (event) => { + captureModelContext(event, await scoped(event.sessionID)) + }) + } + await ctx.session.hook("model.request", async (event) => { await handleModelRequest(event, await scoped(event.sessionID)) }) + if (config.tracePropagationProviders.size > 0) { + try { + await ctx.session.hook("experimental.ws.handshake", async (event) => { + await handleModelRequest(event, await scoped(event.sessionID)) + }) + } catch { + await log("warn", "WebSocket trace propagation hook unavailable", { version: ctx.app.version }) + } + } + const dispatch = async (event: OpenCodeEvent): Promise => { if (event.type === "session.created") { setBoundedMap(tracing.sessionProjects, event.data.sessionID, event.data.projectID) @@ -200,6 +216,9 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> case "session.tool.called": handleToolCalled(event, eventCtx) break + case "session.tool.progress": + handleToolProgress(event, eventCtx) + break case "session.tool.success": handleToolSuccess(event, eventCtx) break diff --git a/src/state.ts b/src/state.ts index 0394d16..177c856 100644 --- a/src/state.ts +++ b/src/state.ts @@ -112,9 +112,11 @@ export function acquireTracingState(): TracingState { runSpans: new Map(), runSpanContexts: new Map(), stepSpans: new Map(), + activeStepSpans: new Map(), stepSpanContexts: new Map(), toolSpans: new Map(), toolSpanContexts: new Map(), + subagentParents: new Map(), toolMeta: new Map(), stepMeta: new Map(), sessionTotals: new Map(), @@ -129,6 +131,7 @@ export function acquireTracingState(): TracingState { pendingPermissions: new Map(), activeLlm: new Map(), provisionalLlm: new Map(), + modelContexts: new Map(), } g[TRACING_KEY] = state } diff --git a/src/types.ts b/src/types.ts index 0a2c166..9419738 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,8 @@ export type ToolMeta = { startMs: number executionStarted?: boolean command?: string + agent?: string + childSessionID?: string } /** Live LLM request metadata used by the `model.request` trace-propagation hook. */ @@ -115,6 +117,15 @@ export type LlmRequestContext = { spanContext: SpanContext } +/** Bounded model-visible text snapshot awaiting its matching primary request. */ +export type ModelContextSnapshot = { + agent: string + providerID: string + modelID: string + inputMessages: string + inputValue?: string +} + /** * Per-process shared OTel SDK instance. OpenCode loads a plugin instance per * location but the OTel global providers may only be registered once, so the @@ -140,9 +151,11 @@ export type TracingState = { runSpans: Map runSpanContexts: Map stepSpans: Map + activeStepSpans: Map stepSpanContexts: Map toolSpans: Map toolSpanContexts: Map + subagentParents: Map toolMeta: Map stepMeta: Map sessionTotals: Map @@ -157,6 +170,7 @@ export type TracingState = { pendingPermissions: Map activeLlm: Map provisionalLlm: Map + modelContexts: Map } /** Shared context threaded through every event handler. */ diff --git a/src/util.ts b/src/util.ts index 2b79d32..d27634c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -141,6 +141,21 @@ export function resolveStepContext(sessionID: string, assistantMessageID: string return spanContext ? trace.setSpanContext(parent, spanContext) : parent } +/** Resolves a child run under its correlated dispatch tool, falling back to the parent run. */ +export function resolveSubagentTraceContext(sessionID: string, parentID: string, agent: string, ctx: HandlerContext): Context { + const exact = ctx.tracing.subagentParents.get(sessionID) + if (exact) return trace.setSpanContext(ctx.rootContext(), exact) + const candidates = [...ctx.tracing.toolMeta] + .filter(([id, meta]) => meta.sessionID === parentID && meta.tool === "subagent" + && (!meta.childSessionID || meta.childSessionID === sessionID) + && (!meta.agent || agent === "unknown" || meta.agent === agent) && ctx.tracing.toolSpans.has(id)) + if (candidates.length === 1) { + const span = ctx.tracing.toolSpans.get(candidates[0]![0])! + return trace.setSpan(ctx.rootContext(), span) + } + return resolveRunContext(parentID, ctx) +} + /** Resolves the current session-scoped agent name/type, defaulting to `unknown` when unavailable. */ export function getSessionAgentMeta( sessionID: string, diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 83f870c..8001dd0 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { handleModelRequest } from "../src/handlers/chat-headers.ts" +import { captureModelContext, handleModelRequest } from "../src/handlers/chat-headers.ts" import { handleStepStarted } from "../src/handlers/step.ts" import { makeCtx, evt } from "./helpers.ts" @@ -45,6 +45,15 @@ describe("handleModelRequest", () => { expect(headers["traceparent"]).toBeUndefined() }) + test("injects the matching context into a WebSocket handshake", async () => { + const { ctx } = makeCtx() + ctx.tracePropagationProviders.add("openai") + ctx.tracing.activeLlm.set("ses_1", { agent: "build", modelID: "m", providerID: "openai", spanContext: SPAN_CONTEXT }) + const handshake = { sessionID: "ses_1", agent: "build", model: { providerID: "openai", id: "m" }, kind: "primary", url: "wss://example.invalid/v1", headers: {} as Record } + await handleModelRequest(handshake, ctx) + expect(handshake.headers["traceparent"]).toBe("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + }) + test("waits for pending event processing and adopts a provisional span", async () => { const { ctx, tracer } = makeCtx() ctx.tracePropagationProviders.add("*") @@ -61,3 +70,32 @@ describe("handleModelRequest", () => { expect(ctx.tracing.stepSpans.get("msg_1")?.spanContext().spanId).toBe(ctx.tracing.activeLlm.get("ses_1")?.spanContext.spanId) }) }) + +describe("captureModelContext", () => { + test("captures a bounded text-only preview and attaches it to the matching LLM span", async () => { + const { ctx, tracer } = makeCtx() + captureModelContext({ + sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, + system: [{ type: "text", text: "policy" }], + messages: [ + { role: "user", content: [{ type: "media", media: { source: { type: "base64", data: "SECRET_BINARY" } } }, { type: "text", text: "x".repeat(10_000) }] }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + ], + }, ctx) + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers: {} }, ctx) + const span = tracer.spans.find((candidate) => candidate.name === "opencode.llm")! + expect(span.attributes["input.value"]).toBe("x".repeat(1_000)) + expect(String(span.attributes["llm.input_messages"])).toContain('"role":"system"') + expect(String(span.attributes["llm.input_messages"])).not.toContain("SECRET_BINARY") + expect(String(span.attributes["llm.input_messages"]).length).toBeLessThan(16_000) + expect(ctx.tracing.modelContexts.size).toBe(0) + }) + + test("does not attach a snapshot from a different model", async () => { + const { ctx, tracer } = makeCtx() + captureModelContext({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "other" }, system: [], messages: [] }, ctx) + ctx.tracePropagationProviders.add("*") + await handleModelRequest({ sessionID: "ses_1", agent: "build", model: { providerID: "vllm", id: "m" }, kind: "primary", headers: {} }, ctx) + expect(tracer.spans[0]?.attributes["llm.input_messages"]).toBeUndefined() + }) +}) diff --git a/tests/config.test.ts b/tests/config.test.ts index d4084c7..7b6a8dc 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -75,6 +75,7 @@ describe("loadConfig", () => { "OPENCODE_DISABLE_METRICS", "OPENCODE_DISABLE_LOGS", "OPENCODE_CAPTURE_PROMPT_IN_LOGS", + "OPENCODE_CAPTURE_MODEL_CONTEXT", "OPENCODE_DISABLE_TRACES", "OPENCODE_TRACE_PROPAGATION_PROVIDERS", "OTEL_EXPORTER_OTLP_HEADERS", @@ -89,6 +90,7 @@ describe("loadConfig", () => { expect(cfg.enabled).toBe(false) expect(cfg.logsEnabled).toBe(true) expect(cfg.capturePromptInLogs).toBe(false) + expect(cfg.captureModelContext).toBe(false) expect(cfg.endpoint).toBe("http://localhost:4317") expect(cfg.protocol).toBe("grpc") expect(cfg.metricsInterval).toBe(60000) @@ -176,6 +178,13 @@ describe("loadConfig", () => { expect(loadConfig().spanAttributes).toBe("team=platform,env=prod") }) + test("captures model-visible context only when explicitly enabled", () => { + expect(loadConfig().captureModelContext).toBe(false) + process.env["OPENCODE_CAPTURE_MODEL_CONTEXT"] = "1" + expect(loadConfig().captureModelContext).toBe(true) + expect(loadConfig({ captureModelContext: false }).captureModelContext).toBe(false) + }) + test("does not set OTEL_EXPORTER_OTLP_HEADERS when OPENCODE_OTLP_HEADERS is unset", () => { delete process.env["OPENCODE_OTLP_HEADERS"] loadConfig() diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 01ed4a7..991fe7a 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -3,6 +3,7 @@ import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers import { handleStepStarted } from "../../src/handlers/step.ts" import { handleToolInputStarted } from "../../src/handlers/tool.ts" import { handleToolCalled } from "../../src/handlers/tool.ts" +import { handleToolProgress, handleToolSuccess } from "../../src/handlers/tool.ts" import { makeCtx, evt } from "../helpers.ts" describe("trace nesting", () => { @@ -24,4 +25,52 @@ describe("trace nesting", () => { expect(step.parentSpan).toBe(run) expect(tool.parentSpan).toBe(step) }) + + test("nests a foreground child under the unique live subagent dispatch span", () => { + const { ctx, tracer, histograms } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "parent", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "parent" }), ctx) + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "parent", assistantMessageID: "m", id: "call_1", name: "subagent" }, 1000), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "parent", assistantMessageID: "m", id: "call_1", input: { agent: "explore" }, executed: false }, 1500), ctx) + handleSessionCreated(evt("session.created", { sessionID: "child", projectID: "p", parentID: "parent", agent: "explore" }), ctx) + handleToolProgress(evt("session.tool.progress", { sessionID: "parent", assistantMessageID: "m", id: "call_1", metadata: { sessionID: "child" } }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "child" }, 1700), ctx) + const tool = tracer.spans.find((span) => span.name === "opencode.tool.subagent")! + const child = tracer.spans.find((span) => span.attributes["session.id"] === "child")! + expect(child.parentSpanContext?.spanId).toBe(tool.spanContext().spanId) + handleToolSuccess(evt("session.tool.success", { sessionID: "parent", assistantMessageID: "m", id: "call_1", content: [{ type: "text", text: "done" }], metadata: { sessionID: "child" }, executed: false }, 1900), ctx) + expect(histograms.tool.calls.at(-1)?.value).toBe(400) + expect(ctx.tracing.subagentParents.get("child")?.spanId).toBe(tool.spanContext().spanId) + expect(tool.attributes["subagent.session_id"]).toBe("child") + }) + + test("nests a background child under the dispatch context after the tool ends", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "parent", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "parent" }), ctx) + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "parent", assistantMessageID: "m", id: "call_1", name: "subagent" }), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "parent", assistantMessageID: "m", id: "call_1", input: { agent: "explore", background: true }, executed: false }), ctx) + handleToolProgress(evt("session.tool.progress", { sessionID: "parent", assistantMessageID: "m", id: "call_1", metadata: { sessionID: "child" } }), ctx) + handleToolSuccess(evt("session.tool.success", { sessionID: "parent", assistantMessageID: "m", id: "call_1", content: [{ type: "text", text: "launched" }], metadata: { sessionID: "child" }, executed: false }), ctx) + handleSessionCreated(evt("session.created", { sessionID: "child", projectID: "p", parentID: "parent", agent: "explore" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "child" }), ctx) + const tool = tracer.spans.find((span) => span.name === "opencode.tool.subagent")! + const child = tracer.spans.find((span) => span.attributes["session.id"] === "child")! + expect(child.parentSpanContext?.spanId).toBe(tool.spanContext().spanId) + }) + + test("falls back to the parent run when two dispatch candidates are ambiguous", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "parent", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "parent" }), ctx) + for (const id of ["a", "b"]) { + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "parent", assistantMessageID: "m", id, name: "subagent" }), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "parent", assistantMessageID: "m", id, input: { agent: "explore" }, executed: true }), ctx) + } + handleSessionCreated(evt("session.created", { sessionID: "child", projectID: "p", parentID: "parent", agent: "explore" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "child" }), ctx) + const parent = tracer.spans.find((span) => span.name === "opencode.session" && span.attributes["session.id"] === "parent")! + const child = tracer.spans.find((span) => span.name === "opencode.session" && span.attributes["session.id"] === "child")! + expect(child.parentSpan).toBe(parent) + }) }) diff --git a/tests/handlers/tool.test.ts b/tests/handlers/tool.test.ts index f20b653..cd31e66 100644 --- a/tests/handlers/tool.test.ts +++ b/tests/handlers/tool.test.ts @@ -99,4 +99,14 @@ describe("handleToolFailed", () => { expect(histograms.tool.calls[0]!.value).toBe(300) expect(tracer.spans.find((span) => span.name === "opencode.tool.read")?.startTime).toBe(5000) }) + + test("does not create a dispatch span for an invalid subagent call without a child", () => { + const { ctx, tracer, histograms } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted("call_2", "subagent"), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_2", input: { agent: "explore" }, executed: false }), ctx) + handleToolFailed(evt("session.tool.failed", { sessionID: "ses_1", assistantMessageID: "msg_1", id: "call_2", error: { type: "invalid", message: "missing input" }, executed: false }), ctx) + expect(tracer.spans.some((span) => span.name === "opencode.tool.subagent")).toBe(false) + expect(histograms.tool.calls).toHaveLength(0) + }) }) diff --git a/tests/helpers.ts b/tests/helpers.ts index 87030d9..886cd6f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -141,9 +141,11 @@ export function makeTracingState(): TracingState { runSpans: new Map(), runSpanContexts: new Map(), stepSpans: new Map(), + activeStepSpans: new Map(), stepSpanContexts: new Map(), toolSpans: new Map(), toolSpanContexts: new Map(), + subagentParents: new Map(), toolMeta: new Map(), stepMeta: new Map(), sessionTotals: new Map(), @@ -158,6 +160,7 @@ export function makeTracingState(): TracingState { pendingPermissions: new Map(), activeLlm: new Map(), provisionalLlm: new Map(), + modelContexts: new Map(), } } From 1706cf553ba607da4eb9faacd993cb3c341c0cd9 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 18:45:50 -0400 Subject: [PATCH 12/13] fix(v2): consume dispatch links once and preserve context previews --- src/handlers/chat-headers.ts | 5 +++-- src/handlers/session.ts | 5 +++++ src/handlers/tool.ts | 4 ++-- src/state.ts | 1 + src/types.ts | 3 ++- src/util.ts | 11 +++++++++-- tests/chat-headers.test.ts | 4 ++++ tests/handlers/spans.test.ts | 23 +++++++++++++++++++++-- tests/helpers.ts | 1 + 9 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index 06c21c3..f544a5b 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -53,6 +53,7 @@ export function applyModelContext( model: { providerID: string; id: string }, span: Span, ctx: HandlerContext, + retain = false, ): void { const snapshot = ctx.tracing.modelContexts.get(sessionID) if (!snapshot || snapshot.agent !== agent || snapshot.providerID !== model.providerID || snapshot.modelID !== model.id) return @@ -60,7 +61,7 @@ export function applyModelContext( [LLM_INPUT_MESSAGES]: snapshot.inputMessages, ...(snapshot.inputValue ? { [INPUT_VALUE]: snapshot.inputValue, [INPUT_MIME_TYPE]: MimeType.TEXT } : {}), }) - ctx.tracing.modelContexts.delete(sessionID) + if (!retain) ctx.tracing.modelContexts.delete(sessionID) } /** Injects the active LLM span's W3C trace context into outbound model requests. */ @@ -96,6 +97,6 @@ export async function handleModelRequest(event: ModelRequestEvent, ctx: HandlerC } if (active.providerID !== providerID || active.modelID !== event.model.id || active.agent !== event.agent) return const span = ctx.tracing.provisionalLlm.get(event.sessionID) ?? ctx.tracing.activeStepSpans.get(event.sessionID) - if (span) applyModelContext(event.sessionID, event.agent, event.model, span, ctx) + if (span) applyModelContext(event.sessionID, event.agent, event.model, span, ctx, ctx.tracing.provisionalLlm.has(event.sessionID)) if (propagate) injectTraceContext(active.spanContext, event.headers) } diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 60ec6ac..3d9c65a 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -237,6 +237,11 @@ export function handleExecutionEnded( const sessionID = e.data.sessionID const totals = ctx.tracing.sessionTotals.get(sessionID) ctx.tracing.activeExecutions.delete(sessionID) + const pendingDispatch = ctx.tracing.subagentParents.get(sessionID) + if (pendingDispatch) { + ctx.tracing.subagentParents.delete(sessionID) + markSeen(ctx.tracing.consumedSubagentDispatches, pendingDispatch.callID) + } sweepExecution(sessionID, ctx) diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts index 077e2d4..ef39a34 100644 --- a/src/handlers/tool.ts +++ b/src/handlers/tool.ts @@ -123,13 +123,13 @@ function startSubagentSpan(callID: string, ctx: HandlerContext) { function linkSubagent(callID: string, childID: unknown, ctx: HandlerContext) { const meta = ctx.tracing.toolMeta.get(callID) - if (typeof childID !== "string" || meta?.tool !== "subagent") return + if (typeof childID !== "string" || meta?.tool !== "subagent" || ctx.tracing.consumedSubagentDispatches.has(callID)) return setBoundedMap(ctx.tracing.toolMeta, callID, { ...meta, childSessionID: childID }) startSubagentSpan(callID, ctx) const span = ctx.tracing.toolSpans.get(callID) span?.setAttribute("subagent.session_id", childID) const spanContext = span?.spanContext() - if (spanContext) setBoundedMap(ctx.tracing.subagentParents, childID, spanContext) + if (spanContext) setBoundedMap(ctx.tracing.subagentParents, childID, { spanContext, callID }) } export function handleToolProgress(e: EventOf<"session.tool.progress">, ctx: HandlerContext) { diff --git a/src/state.ts b/src/state.ts index 177c856..e3395c0 100644 --- a/src/state.ts +++ b/src/state.ts @@ -117,6 +117,7 @@ export function acquireTracingState(): TracingState { toolSpans: new Map(), toolSpanContexts: new Map(), subagentParents: new Map(), + consumedSubagentDispatches: new Set(), toolMeta: new Map(), stepMeta: new Map(), sessionTotals: new Map(), diff --git a/src/types.ts b/src/types.ts index 9419738..07468e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -155,7 +155,8 @@ export type TracingState = { stepSpanContexts: Map toolSpans: Map toolSpanContexts: Map - subagentParents: Map + subagentParents: Map + consumedSubagentDispatches: Set toolMeta: Map stepMeta: Map sessionTotals: Map diff --git a/src/util.ts b/src/util.ts index d27634c..9a45d3b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -144,13 +144,20 @@ export function resolveStepContext(sessionID: string, assistantMessageID: string /** Resolves a child run under its correlated dispatch tool, falling back to the parent run. */ export function resolveSubagentTraceContext(sessionID: string, parentID: string, agent: string, ctx: HandlerContext): Context { const exact = ctx.tracing.subagentParents.get(sessionID) - if (exact) return trace.setSpanContext(ctx.rootContext(), exact) + if (exact) { + ctx.tracing.subagentParents.delete(sessionID) + markSeen(ctx.tracing.consumedSubagentDispatches, exact.callID) + return trace.setSpanContext(ctx.rootContext(), exact.spanContext) + } const candidates = [...ctx.tracing.toolMeta] .filter(([id, meta]) => meta.sessionID === parentID && meta.tool === "subagent" && (!meta.childSessionID || meta.childSessionID === sessionID) + && !ctx.tracing.consumedSubagentDispatches.has(id) && (!meta.agent || agent === "unknown" || meta.agent === agent) && ctx.tracing.toolSpans.has(id)) if (candidates.length === 1) { - const span = ctx.tracing.toolSpans.get(candidates[0]![0])! + const callID = candidates[0]![0] + markSeen(ctx.tracing.consumedSubagentDispatches, callID) + const span = ctx.tracing.toolSpans.get(callID)! return trace.setSpan(ctx.rootContext(), span) } return resolveRunContext(parentID, ctx) diff --git a/tests/chat-headers.test.ts b/tests/chat-headers.test.ts index 8001dd0..f0272d5 100644 --- a/tests/chat-headers.test.ts +++ b/tests/chat-headers.test.ts @@ -88,6 +88,10 @@ describe("captureModelContext", () => { expect(String(span.attributes["llm.input_messages"])).toContain('"role":"system"') expect(String(span.attributes["llm.input_messages"])).not.toContain("SECRET_BINARY") expect(String(span.attributes["llm.input_messages"]).length).toBeLessThan(16_000) + expect(ctx.tracing.modelContexts.size).toBe(1) + ctx.tracing.activePrompts.set("ses_1", { text: "admitted prompt", startMs: 1 }) + handleStepStarted(evt("session.step.started", { sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "vllm" }, started: 1 }), ctx) + expect(span.attributes["input.value"]).toBe("x".repeat(1_000)) expect(ctx.tracing.modelContexts.size).toBe(0) }) diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 991fe7a..44ae196 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" +import { finalizeSession, handleExecutionEnded, handleExecutionStarted, handleSessionCreated } from "../../src/handlers/session.ts" import { handleStepStarted } from "../../src/handlers/step.ts" import { handleToolInputStarted } from "../../src/handlers/tool.ts" import { handleToolCalled } from "../../src/handlers/tool.ts" @@ -40,7 +40,8 @@ describe("trace nesting", () => { expect(child.parentSpanContext?.spanId).toBe(tool.spanContext().spanId) handleToolSuccess(evt("session.tool.success", { sessionID: "parent", assistantMessageID: "m", id: "call_1", content: [{ type: "text", text: "done" }], metadata: { sessionID: "child" }, executed: false }, 1900), ctx) expect(histograms.tool.calls.at(-1)?.value).toBe(400) - expect(ctx.tracing.subagentParents.get("child")?.spanId).toBe(tool.spanContext().spanId) + expect(ctx.tracing.consumedSubagentDispatches.has("call_1")).toBe(true) + expect(ctx.tracing.subagentParents.has("child")).toBe(false) expect(tool.attributes["subagent.session_id"]).toBe("child") }) @@ -73,4 +74,22 @@ describe("trace nesting", () => { const child = tracer.spans.find((span) => span.name === "opencode.session" && span.attributes["session.id"] === "child")! expect(child.parentSpan).toBe(parent) }) + + test("does not reuse an earlier dispatch when the child executes again", () => { + const { ctx, tracer } = makeCtx() + handleSessionCreated(evt("session.created", { sessionID: "parent", projectID: "p" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "parent" }), ctx) + handleToolInputStarted(evt("session.tool.input.started", { sessionID: "parent", assistantMessageID: "m", id: "call_1", name: "subagent" }), ctx) + handleToolCalled(evt("session.tool.called", { sessionID: "parent", assistantMessageID: "m", id: "call_1", input: { agent: "explore" }, executed: false }), ctx) + handleToolProgress(evt("session.tool.progress", { sessionID: "parent", assistantMessageID: "m", id: "call_1", metadata: { sessionID: "child" } }), ctx) + handleSessionCreated(evt("session.created", { sessionID: "child", projectID: "p", parentID: "parent", agent: "explore" }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "child" }), ctx) + handleExecutionEnded(evt("session.execution.succeeded", { sessionID: "child" }), ctx, { type: "succeeded" }) + finalizeSession("child", ctx) + handleToolSuccess(evt("session.tool.success", { sessionID: "parent", assistantMessageID: "m", id: "call_1", content: [{ type: "text", text: "done" }], metadata: { sessionID: "child" }, executed: false }), ctx) + handleExecutionStarted(evt("session.execution.started", { sessionID: "child" }), ctx) + const parent = tracer.spans.find((span) => span.name === "opencode.session" && span.attributes["session.id"] === "parent")! + const resumed = tracer.spans.filter((span) => span.name === "opencode.session" && span.attributes["session.id"] === "child").at(-1)! + expect(resumed.parentSpan).toBe(parent) + }) }) diff --git a/tests/helpers.ts b/tests/helpers.ts index 886cd6f..09175ee 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -146,6 +146,7 @@ export function makeTracingState(): TracingState { toolSpans: new Map(), toolSpanContexts: new Map(), subagentParents: new Map(), + consumedSubagentDispatches: new Set(), toolMeta: new Map(), stepMeta: new Map(), sessionTotals: new Map(), From 3db060c517fe5fb83284b61c65b70315e6bd1162 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sat, 26 Sep 2026 18:48:32 -0400 Subject: [PATCH 13/13] fix(v2): retain usage attributes on failed LLM spans --- src/handlers/step.ts | 9 +++++++++ tests/handlers/step.test.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/handlers/step.ts b/src/handlers/step.ts index 0978fa0..b87d11b 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -250,6 +250,15 @@ export function handleStepFailed(e: EventOf<"session.step.failed">, ctx: Handler "agent.type": agentType, [LLM_FINISH_REASON]: d.finish ?? "error", duration_ms: durationMs, + ...(d.tokens ? { + [LLM_TOKEN_COUNT_PROMPT]: d.tokens.input, + [LLM_TOKEN_COUNT_COMPLETION]: d.tokens.output, + [LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: d.tokens.reasoning, + [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]: d.tokens.cache.read, + [LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]: d.tokens.cache.write, + [LLM_TOKEN_COUNT_TOTAL]: allTokens(d.tokens), + } : {}), + ...(d.cost !== undefined ? { [LLM_COST_TOTAL]: d.cost, cost_usd: d.cost } : {}), ...(output ? { [OUTPUT_VALUE]: output, [OUTPUT_MIME_TYPE]: MimeType.TEXT } : {}), }) span.setStatus({ code: SpanStatusCode.ERROR, message: error }) diff --git a/tests/handlers/step.test.ts b/tests/handlers/step.test.ts index a2d305c..ee36269 100644 --- a/tests/handlers/step.test.ts +++ b/tests/handlers/step.test.ts @@ -82,6 +82,8 @@ describe("handleStepFailed", () => { expect(counters.token.calls.length).toBeGreaterThan(0) const llm = tracer.spans.find((s) => s.name === "opencode.llm")! expect(llm.status.code).toBe(SpanStatusCode.ERROR) + expect(llm.attributes["llm.token_count.total"]).toBe(15) + expect(llm.attributes["llm.cost.total"]).toBe(0.1) expect(logger.records.at(-1)!.body).toBe("api_error") expect(logger.records.at(-1)!.attributes?.["error"]).toBe("ProviderError: boom") expect(counters.message.calls).toHaveLength(1)