diff --git a/.github/workflows/codex.yml b/.github/workflows/codex.yml deleted file mode 100644 index ff11aa0..0000000 --- a/.github/workflows/codex.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Codex Review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - -jobs: - codex: - name: Review Pull Request - if: | - github.event.pull_request.draft == false && - github.actor != 'dependabot[bot]' && - !startsWith(github.head_ref, 'release-please--branches--') && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - outputs: - final_message: ${{ steps.run_codex.outputs.final-message }} - steps: - - name: Checkout pull request - uses: actions/checkout@v6 - with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge - persist-credentials: false - - - name: Fetch pull request refs - env: - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - git fetch --no-tags origin \ - "$PR_BASE_REF" \ - "+refs/pull/$PR_NUMBER/head" - - - name: Run Codex review - id: run_codex - uses: openai/codex-action@v1 - with: - openai-api-key: ${{ secrets.OPENAI_API_KEY }} - permission-profile: ":workspace" - prompt: | - This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. - - Review only the changes introduced by this pull request. Focus on bugs, - behavior regressions, security risks, and missing tests. Keep feedback - concise and specific, with file and line references where possible. - - Useful commands: - git log --oneline ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} - git diff --stat ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} - git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} - - Pull request title and body: - ---- - ${{ github.event.pull_request.title }} - ${{ github.event.pull_request.body }} - - post_feedback: - name: Post Codex Feedback - needs: codex - if: | - needs.codex.result == 'success' && - needs.codex.outputs.final_message != '' - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - steps: - - name: Comment on pull request - uses: actions/github-script@v7 - env: - CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} - with: - github-token: ${{ github.token }} - script: | - const marker = ''; - const body = `${marker}\n${process.env.CODEX_FINAL_MESSAGE}`; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - }); - const existing = comments.find((comment) => - comment.user.type === 'Bot' && comment.body?.includes(marker) - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - } diff --git a/AGENTS.md b/AGENTS.md index 916519f..deefffd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,9 +61,16 @@ src/ - **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. +- **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`, `OPENCODE_METRIC_ATTRIBUTES`, `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES`, `OPENCODE_COST_USAGE_SCALE`. 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. +- **Attribute channels are per-signal and independent** — all parsed with `parseAttributePairs` (`src/config.ts`): + - `OPENCODE_RESOURCE_ATTRIBUTES` → OTel `Resource` (producer metadata), applied in `buildResource` (`src/otel.ts`). + - `OPENCODE_SPAN_ATTRIBUTES` → **spans only**, threaded as `ctx.spanAttributes` and spread into each `tracer.startSpan` attribute block (run/llm/tool). Not in `commonAttrs`, so it never reaches logs or metrics. + - `OPENCODE_METRIC_ATTRIBUTES` → **metrics only**, merged via instrument wrapping in `createInstruments` (`src/otel.ts`, called from `acquireSharedOtel`); never touches spans or logs. + - `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES` → **metrics only**, attribute keys stripped from each metric data point in `createInstruments` (e.g. `session.id`); applied last so it always wins over `OPENCODE_METRIC_ATTRIBUTES`. + - `commonAttrs` (just the per-session `project.id`) is the only set applied to all three signals. - **`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. +- **`OPENCODE_COST_USAGE_SCALE`** — positive number (default `1`); multiplier applied **only** to the `cost.usage` counter (`recordUsageMetrics` in `src/handlers/step.ts`). Workaround for backends that round metric values to one decimal place (e.g. AppSignal). `session.cost.total`, spans, and log events keep raw USD. When ≠ 1, the `cost.usage` unit is `USD/`. - **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 diff --git a/README.md b/README.md index 5b87b73..5b0e437 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet - [Datadog example](#datadog-example) - [Honeycomb example](#honeycomb-example) - [Claude Code dashboard compatibility](#claude-code-dashboard-compatibility) + - [Scaling cost.usage for backends that round metric values (`OPENCODE_COST_USAGE_SCALE`)](#scaling-costusage-for-backends-that-round-metric-values) - [Local development](#local-development) - [GitHub Discord notifications](#github-discord-notifications) @@ -148,9 +149,12 @@ The environment variables (set them in your shell profile — `~/.zshrc`, `~/.ba | `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`. | -| `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_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel **resource** (describes the producer). Example: `service.version=1.2.3,deployment.environment=production` | +| `OPENCODE_SPAN_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs added to **spans only** (not logs, metrics, or the resource). Example: `team=platform,deployment.environment=production` | +| `OPENCODE_METRIC_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs added to **metric data points only** (not spans, logs, or the resource). Example: `deployment.environment=production` | +| `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES` | *(unset)* | Comma-separated attribute keys stripped from **metric data points only** (not spans or logs). Applied last, so it always wins over `OPENCODE_METRIC_ATTRIBUTES`. Example: `session.id` to avoid high-cardinality session labels on counters and histograms | | `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_COST_USAGE_SCALE` | `1` | Multiplier applied to USD cost values before they are recorded as the `cost.usage` counter. Set to `1000000` for backends that round metric values to one decimal place (e.g. AppSignal) so per-message cents land as whole numbers. Only affects `cost.usage` — `session.cost.total`, spans, and log events keep raw USD. Divide by this value in dashboards to display dollars. | | `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. | @@ -198,6 +202,9 @@ Option keys mirror the resolved config and map to the environment variables: | `otlpHeadersHelper` | `OPENCODE_OTLP_HEADERS_HELPER` | | `resourceAttributes` | `OPENCODE_RESOURCE_ATTRIBUTES` | | `spanAttributes` | `OPENCODE_SPAN_ATTRIBUTES` | +| `metricAttributes` | `OPENCODE_METRIC_ATTRIBUTES` | +| `excludeMetricAttributes` | `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES` (array, not a comma string) | +| `costUsageScale` | `OPENCODE_COST_USAGE_SCALE` | | `traceparent` | `OPENCODE_TRACEPARENT` | | `tracestate` | `OPENCODE_TRACESTATE` | | `metricsTemporality` | `OPENCODE_OTLP_METRICS_TEMPORALITY` | @@ -226,19 +233,19 @@ For `OPENCODE_OTLP_PROTOCOL=http/protobuf` or `OPENCODE_OTLP_PROTOCOL=http/json` # Auth token for a managed collector (e.g. Honeycomb, Grafana Cloud) export OPENCODE_OTLP_HEADERS="x-honeycomb-team=your-api-key,x-honeycomb-dataset=opencode" -# Tag every metric and log with deployment context +# Tag the OTel resource (producer metadata) with deployment context export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environment=production" -# Tag every span, log event, and metric point with filterable attributes -export OPENCODE_SPAN_ATTRIBUTES="team=platform,deployment.environment=production" -``` +# Tag spans only (e.g. high-cardinality dimensions for trace drill-down) +export OPENCODE_SPAN_ATTRIBUTES="team=platform" -> **Security note:** `OPENCODE_OTLP_HEADERS` typically contains auth tokens. Set it in your shell profile (`~/.zshrc`, `~/.bashrc`) or a secrets manager — never commit it to version control or print it in CI logs. +# Tag metric data points only (e.g. low-cardinality dashboard dimensions) +export OPENCODE_METRIC_ATTRIBUTES="deployment.environment=production" +``` -`OPENCODE_RESOURCE_ATTRIBUTES` and `OPENCODE_SPAN_ATTRIBUTES` are independent: +Each attribute channel targets a different signal: `OPENCODE_RESOURCE_ATTRIBUTES` → the OTel `Resource` (producer metadata); `OPENCODE_SPAN_ATTRIBUTES` → spans only; `OPENCODE_METRIC_ATTRIBUTES` → metric data points only. They are independent — a key set in one does not appear in the others. -- Use `OPENCODE_RESOURCE_ATTRIBUTES` for producer metadata on the OTel Resource. -- Use `OPENCODE_SPAN_ATTRIBUTES` for attributes that need to appear on each span, log event, and metric data point for filtering or grouping in backends. +> **Security note:** `OPENCODE_OTLP_HEADERS` typically contains auth tokens. Set it in your shell profile (`~/.zshrc`, `~/.bashrc`) or a secrets manager — never commit it to version control or print it in CI logs. ### Dynamic headers @@ -407,6 +414,21 @@ export OPENCODE_OTLP_HEADERS="Authorization=Basic " export OPENCODE_METRIC_PREFIX=claude_code. ``` +### Scaling cost.usage for backends that round metric values + +Some observability backends round metric values to one decimal place server-side (AppSignal is one example). Per-message cost values like `$0.023` then collapse to `0.0` and dashboards look empty. + +Use `OPENCODE_COST_USAGE_SCALE` to multiply the value sent to `cost.usage` so the integer part is meaningful. Only `cost.usage` is affected — `session.cost.total`, spans, and log events still carry the raw USD value. + +```bash +# Scale to millionths of a dollar — $0.023 → 23000 +export OPENCODE_COST_USAGE_SCALE=1000000 +``` + +When querying the metric, divide by the same factor to get dollars back. With `OPENCODE_COST_USAGE_SCALE=1000000`, a `cost.usage` value of `23000` is `$0.023`. + +The `cost.usage` `unit` is updated to `USD/1000000` (or whatever scale you set) and the description is annotated so the unit is visible in the backend's metric catalog. + ## Local development See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a7359a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2094 @@ +{ + "name": "@multiwoven-user/opencode-plugin-otel", + "version": "1.4.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@multiwoven-user/opencode-plugin-otel", + "version": "1.4.1", + "license": "MPL-2.0", + "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.6.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-metrics": "^2.6.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "typescript": "^5.9.3" + }, + "devDependencies": { + "@types/bun": "latest", + "@typescript-eslint/parser": "^8.30.1", + "eslint": "^9.24.0", + "eslint-plugin-jsdoc": "^50.6.11" + } + }, + "node_modules/@arizeai/openinference-semantic-conventions": { + "version": "2.2.0", + "license": "Apache-2.0" + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.50.2", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "license": "Apache-2.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" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.20", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.20", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.6.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.213.0", + "license": "Apache-2.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" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/sdk-logs": "0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.213.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-metrics": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-http": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-metrics": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.213.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-metrics": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.213.0", + "license": "Apache-2.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/resources": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-transformer": "0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-grpc-exporter-base": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/otlp-exporter-base": "0.213.0", + "@opentelemetry/otlp-transformer": "0.213.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/sdk-logs": "0.213.0", + "@opentelemetry/sdk-metrics": "2.6.0", + "@opentelemetry/sdk-trace-base": "2.6.0", + "protobufjs": "^7.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.213.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.6.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.40.0", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.5", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "license": "MIT", + "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" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "50.8.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.50.2", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.4.1", + "escape-string-regexp": "^4.0.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "license": "Apache-2.0" + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "license": "Apache-2.0" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "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" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "license": "MIT", + "optional": true, + "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" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 847ce63..3d1084a 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,12 @@ }, "exports": { ".": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" }, "./server": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "default": "./dist/index.js" } }, "files": [ @@ -57,7 +57,7 @@ "license": "MPL-2.0", "main": "dist/index.js", "module": "dist/index.js", - "name": "@devtheops/opencode-plugin-otel", + "name": "@multiwoven-user/opencode-plugin-otel", "oc-plugin": [ "server" ], @@ -86,5 +86,5 @@ }, "type": "module", "types": "dist/index.d.ts", - "version": "2.0.1" + "version": "2.0.2" } diff --git a/package/LICENSE b/package/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/package/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/package/README.md b/package/README.md new file mode 100644 index 0000000..b49ad87 --- /dev/null +++ b/package/README.md @@ -0,0 +1,356 @@ +# opencode-plugin-otel + +[![npm version](https://img.shields.io/npm/v/@devtheops/opencode-plugin-otel.svg)](https://www.npmjs.com/package/@devtheops/opencode-plugin-otel) +[![npm downloads](https://img.shields.io/npm/dm/@devtheops/opencode-plugin-otel.svg)](https://www.npmjs.com/package/@devtheops/opencode-plugin-otel) +[![GitHub stars](https://img.shields.io/github/stars/DEVtheOPS/opencode-plugin-otel.svg)](https://github.com/DEVtheOPS/opencode-plugin-otel/stargazers) +[![Build status](https://img.shields.io/github/actions/workflow/status/DEVtheOPS/opencode-plugin-otel/release-please.yml?branch=main)](https://github.com/DEVtheOPS/opencode-plugin-otel/actions/workflows/release-please.yml) +[![Discord notifications](https://img.shields.io/badge/discord-notifications-5865F2?logo=discord&logoColor=white)](https://discord.gg/zavuskz8xB) +[![License](https://img.shields.io/npm/l/@devtheops/opencode-plugin-otel.svg)](https://github.com/DEVtheOPS/opencode-plugin-otel/blob/main/LICENSE) + +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). + +- [What it instruments](#what-it-instruments) + - [Metrics](#metrics) + - [Log events](#log-events) +- [Installation](#installation) +- [Configuration](#configuration) + - [Quick start](#quick-start) + - [Headers and resource attributes](#headers-and-resource-attributes) + - [Dynamic headers](#dynamic-headers) + - [Disabling specific metrics](#disabling-specific-metrics) + - [Disabling OTLP logs (`OPENCODE_DISABLE_LOGS`)](#disabling-otlp-logs) + - [Disabling traces (`OPENCODE_DISABLE_TRACES`)](#disabling-traces) + - [Session finalization (debounced)](#session-finalization-debounced) + - [SigNoz example](#signoz-example) + - [Datadog example](#datadog-example) + - [Honeycomb example](#honeycomb-example) + - [Claude Code dashboard compatibility](#claude-code-dashboard-compatibility) +- [Local development](#local-development) +- [GitHub Discord notifications](#github-discord-notifications) + +## What it instruments + +### Metrics + +| 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.tool.duration` | Histogram | Tool execution time in milliseconds | +| `opencode.cache.count` | Counter | Cache activity per message: `type=cacheRead` or `type=cacheCreation` | +| `opencode.session.duration` | Histogram | Session duration from created to idle in milliseconds, recorded once per session via a debounced finalize (see [Session finalization](#session-finalization-debounced)) | +| `opencode.message.count` | Counter | Completed assistant messages per session | +| `opencode.session.token.total` | Histogram | Total tokens consumed per session, recorded once per session via a debounced finalize (see [Session finalization](#session-finalization-debounced)) | +| `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded once per session via a debounced finalize (see [Session finalization](#session-finalization-debounced)) | +| `opencode.model.usage` | Counter | Messages per model and provider | +| `opencode.retry.count` | Counter | API retries observed via `session.status` events | + +### Log events + +| Event | Description | +|-------|-------------| +| `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`) | +| `api_request` | Completed assistant message (tokens, cost, duration) | +| `api_error` | Failed assistant message (error summary, duration) | +| `tool_result` | Tool completed or errored (duration, success, output size) | +| `tool_decision` | Permission prompt answered (accept/reject) | +| `commit` | Git commit detected | + +## Installation + +Add the plugin to your opencode config at `~/.config/opencode/opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@devtheops/opencode-plugin-otel"] +} +``` + +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"] +} +``` + +## Configuration + +All configuration is via environment variables. Set them in your shell profile (`~/.zshrc`, `~/.bashrc`, etc.). + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENCODE_ENABLE_TELEMETRY` | *(unset)* | Set to any non-empty value to enable the plugin | +| `OPENCODE_OTLP_ENDPOINT` | `http://localhost:4317` | OTLP collector endpoint. Always include a URL scheme. For `grpc`, use the collector URL (for example `http://localhost:4317` or `grpc://collector:4317`). For `http/protobuf` and `http/json`, use the base URL and the plugin will append `/v1/traces`, `/v1/metrics`, and `/v1/logs`. | +| `OPENCODE_OTLP_PROTOCOL` | `grpc` | OTLP transport protocol: `grpc`, `http/protobuf`, or `http/json` | +| `OPENCODE_OTLP_METRICS_INTERVAL` | `60000` | Metrics export interval in milliseconds | +| `OPENCODE_OTLP_LOGS_INTERVAL` | `5000` | Logs export interval in milliseconds | +| `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_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`. | +| `OPENCODE_RESOURCE_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs merged into the OTel **resource** (describes the producer). Example: `service.version=1.2.3,deployment.environment=production` | +| `OPENCODE_SPAN_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs added to **spans only** (not logs, metrics, or the resource). Example: `team=platform,deployment.environment=production` | +| `OPENCODE_METRIC_ATTRIBUTES` | *(unset)* | Comma-separated `key=value` pairs added to **metric data points only** (not spans, logs, or the resource). Example: `deployment.environment=production` | +| `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES` | *(unset)* | Comma-separated attribute keys stripped from **metric data points only** (not spans or logs). Applied last, so it always wins over `OPENCODE_METRIC_ATTRIBUTES`. Example: `session.id` to avoid high-cardinality session labels on counters and histograms | +| `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_SESSION_FINALIZE_DELAY_MS` | `60000` | Debounce delay before the three session-summary histograms (`session.duration`, `session.token.total`, `session.cost.total`) are recorded. Resets on any new `message.updated` / `message.part.updated`. See [Session finalization](#session-finalization-debounced). | +| `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. | + +### Quick start + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=http://localhost:4317 +export OPENCODE_OTLP_PROTOCOL=grpc +opencode +``` + +Always set `OPENCODE_OTLP_ENDPOINT` to a full URL with a scheme. Scheme-less values like `localhost:4317` are rejected. + +For `OPENCODE_OTLP_PROTOCOL=http/protobuf` or `OPENCODE_OTLP_PROTOCOL=http/json`, set `OPENCODE_OTLP_ENDPOINT` to the collector base URL rather than a per-signal path. The plugin expands it to `/v1/traces`, `/v1/metrics`, and `/v1/logs` automatically. + +### Headers and resource attributes + +```bash +# Auth token for a managed collector (e.g. Honeycomb, Grafana Cloud) +export OPENCODE_OTLP_HEADERS="x-honeycomb-team=your-api-key,x-honeycomb-dataset=opencode" + +# Tag the OTel resource (producer metadata) with deployment context +export OPENCODE_RESOURCE_ATTRIBUTES="service.version=1.2.3,deployment.environment=production" + +# Tag spans only (e.g. high-cardinality dimensions for trace drill-down) +export OPENCODE_SPAN_ATTRIBUTES="team=platform" + +# Tag metric data points only (e.g. low-cardinality dashboard dimensions) +export OPENCODE_METRIC_ATTRIBUTES="deployment.environment=production" +``` + +Each attribute channel targets a different signal: `OPENCODE_RESOURCE_ATTRIBUTES` → the OTel `Resource` (producer metadata); `OPENCODE_SPAN_ATTRIBUTES` → spans only; `OPENCODE_METRIC_ATTRIBUTES` → metric data points only. They are independent — a key set in one does not appear in the others. + +> **Security note:** `OPENCODE_OTLP_HEADERS` typically contains auth tokens. Set it in your shell profile (`~/.zshrc`, `~/.bashrc`) or a secrets manager — never commit it to version control or print it in CI logs. + +### Dynamic headers + +Use `OPENCODE_OTLP_HEADERS_HELPER` when your collector requires short-lived authentication tokens. When this is set, the plugin prewarms the helper once during startup so the first export can use fresh credentials. If a later OTLP export fails with an authentication error (`401`/`403` for HTTP or `UNAUTHENTICATED`/`PERMISSION_DENIED` for gRPC), the plugin refreshes headers again, rebuilds the exporter, and retries the failed export once. + +```bash +export OPENCODE_OTLP_HEADERS_HELPER=/path/to/opencode-otel-headers.sh +``` + +Use an absolute helper path. If you need the path to follow the current project, `OPENCODE_OTLP_HEADERS_HELPER` also supports `${PROJECT_ROOT}`, `${WORKTREE}`, and `${DIRECTORY}` placeholders. + +```bash +export OPENCODE_OTLP_HEADERS_HELPER='${PROJECT_ROOT}/scripts/opencode-otel-headers.sh' +``` + +The helper must be executable and print a JSON object to stdout: + +```bash +#!/bin/sh +printf '{"Authorization":"Bearer %s"}' "$(get-token.sh)" +``` + +For a Cloud Run collector using IAM authentication, `get-token.sh` might be `gcloud auth print-identity-token`. + +If `OPENCODE_OTLP_HEADERS` is also set, helper-provided headers override static headers with the same name. Header values are never logged. + +### 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). + +Disabling a metric only stops the counter/histogram from being incremented — the corresponding log events are still emitted. + +```bash +# Disable a single metric +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 + +The following metrics are specific to opencode and have no equivalent in Claude Code's built-in monitoring. If you are using a Claude Code dashboard and want to avoid cluttering it with opencode-only metrics, you can disable them: + +```bash +export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count" +``` + +| Metric suffix | Why it's opencode-only | +|---------------|------------------------| +| `cache.count` | Tracks cache read/write activity as occurrence counts — not a Claude Code signal | +| `session.duration` | Session wall-clock duration — not emitted by Claude Code | +| `session.token.total` | Per-session token histogram — not emitted by Claude Code | +| `session.cost.total` | Per-session cost histogram — not emitted by Claude Code | +| `model.usage` | Per-model message counter — not emitted by Claude Code | +| `retry.count` | API retry counter — not emitted by Claude Code | +| `message.count` | Completed message counter — not emitted by Claude Code | + +### Disabling OTLP logs + +Use `OPENCODE_DISABLE_LOGS` to suppress every OTLP log event emitted by the plugin. + +```bash +export OPENCODE_DISABLE_LOGS=1 +``` + +This only disables OTLP logs. Metrics and traces continue to be exported unless they are disabled separately. + +### Disabling traces + +Use `OPENCODE_DISABLE_TRACES` to suppress one or more trace types. + +```bash +# Disable one trace type +export OPENCODE_DISABLE_TRACES="tool" + +# Disable multiple trace types +export OPENCODE_DISABLE_TRACES="llm,tool" + +# Disable every trace type explicitly +export OPENCODE_DISABLE_TRACES="all" +``` + +Accepted explicit "disable all traces" values are `all`, `*`, `true`, and `1`. + +### Session finalization (debounced) + +opencode emits `session.idle` every time a session transitions to idle — which in a long multi-turn conversation happens after every assistant turn, not just at the true end. To make the three session-summary histograms reflect the **full** session totals (not partial first-turn totals), the plugin defers their recording behind a debounce. + +What this means in practice: + +- On every `session.idle` event: the `session.idle` log event is emitted, the session span is ended, pending tool/permission state is swept, **and** a finalize timer is scheduled (default 60s). +- Any subsequent `message.updated` or `message.part.updated` on the same session **cancels** the pending timer — the session is alive again. +- After `OPENCODE_SESSION_FINALIZE_DELAY_MS` of inactivity, the timer fires and records the three session-summary histograms (`session.duration`, `session.token.total`, `session.cost.total`) with the true accumulated totals, then clears the session's totals. +- `session.deleted` finalizes immediately (no wait). +- On plugin shutdown (`SIGTERM` / `SIGINT` / `beforeExit`), every pending finalize is flushed before the OTel providers shut down, so nothing is lost on container teardown. + +Pick the delay based on your deployment shape: + +```bash +# Long-running local opencode / TUI — leave the default +# (sessions can sit idle for a while between turns) +# OPENCODE_SESSION_FINALIZE_DELAY_MS=60000 + +# Short-lived containers (Modal, Lambda, Cloud Run jobs) — +# shorten the debounce so finalize fires before the container exits +export OPENCODE_SESSION_FINALIZE_DELAY_MS=15000 + +# Pair it with a short metrics export interval so the OTLP batch +# flushes before teardown +export OPENCODE_OTLP_METRICS_INTERVAL=5000 +``` + +The per-message `opencode.cost.usage` counter is recorded synchronously and is **not** affected by this debounce — only the three per-session histograms are. To get per-session cost without waiting for finalize, sum `opencode.cost.usage` grouped by `session.id` in your dashboard. + +### SigNoz example + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT="https://ingest.us.signoz.cloud:443" +export OPENCODE_OTLP_HEADERS="signoz-ingestion-key=" +``` + +> Use `https://ingest.in.signoz.cloud:443` for India, `https://ingest.eu2.signoz.cloud:443` for EU2, etc. +> See [SigNoz setup docs](https://signoz.io/docs/cloud/) for all regions. + +### Datadog example + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=https://otlp.datadoghq.com +export OPENCODE_OTLP_PROTOCOL=http/protobuf +export OPENCODE_OTLP_HEADERS="dd-api-key=YOUR_DATADOG_API_KEY" + +# Required — Datadog's OTLP intake only accepts delta temporality +export OPENCODE_OTLP_METRICS_TEMPORALITY=delta +``` + +> **Note:** The endpoint is `otlp.datadoghq.com` (not `api.datadoghq.com`). +> Use `otlp.datadoghq.eu` for EU, `otlp.us3.datadoghq.com` for US3, etc. +> See [Datadog OTLP docs](https://docs.datadoghq.com/opentelemetry/setup/otlp_ingest_in_the_agent/) for all regions. + +### Honeycomb example + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=https://api.honeycomb.io +export OPENCODE_OTLP_PROTOCOL=http/protobuf +``` + +### Grafana Cloud example + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp +export OPENCODE_OTLP_PROTOCOL=http/protobuf +export OPENCODE_OTLP_HEADERS="Authorization=Basic " +``` + +### Claude Code dashboard compatibility + +```bash +export OPENCODE_METRIC_PREFIX=claude_code. +``` + +## Local development + +See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## GitHub Discord notifications + +This repo includes a reusable workflow at `.github/workflows/discord-notify.yml` that posts a Discord embed for supported GitHub events. The included `.github/workflows/discord-events.yml` file wires it up for: + +- `issues.opened` +- `pull_request.opened` +- `release.published` + +Set an org or repo secret named `DISCORD_WEBHOOK` and the workflow will post to that webhook automatically. + +To reuse it from another repository in the `DEVtheOPS` org: + +```yaml +name: Discord Events + +on: + issues: + types: [opened] + pull_request: + types: [opened] + release: + types: [published] + +jobs: + notify-discord: + uses: DEVtheOPS/opencode-plugin-otel/.github/workflows/discord-notify.yml@main + with: + username: DEVtheOPS Bot + title_prefix: "[DEVtheOPS]" + include_body: true + secrets: + discord_webhook: ${{ secrets.DISCORD_WEBHOOK }} +``` + +Available workflow inputs: + +- `username`: webhook display name +- `avatar_url`: webhook avatar image URL +- `title_prefix`: optional title prefix for the embed +- `include_body`: include the issue, PR, or release body in the card +- `color`: fallback embed color for unsupported events diff --git a/package/package.json b/package/package.json new file mode 100644 index 0000000..e3ddf6d --- /dev/null +++ b/package/package.json @@ -0,0 +1,81 @@ +{ + "author": "DEVtheOPS", + "bugs": { + "url": "https://github.com/DEVtheOPS/opencode-plugin-otel/issues" + }, + "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.6.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.213.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", + "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-metrics": "^2.6.0", + "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "typescript": "^5.9.3" + }, + "description": "OpenTelemetry telemetry plugin for opencode CLI", + "devDependencies": { + "@types/bun": "latest", + "@typescript-eslint/parser": "^8.30.1", + "eslint": "^9.24.0", + "eslint-plugin-jsdoc": "^50.6.11" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist/" + ], + "homepage": "https://github.com/DEVtheOPS/opencode-plugin-otel#readme", + "keywords": [ + "opentelemetry", + "otel", + "telemetry", + "opencode", + "plugin", + "observability" + ], + "license": "MPL-2.0", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "name": "@multiwoven-user/opencode-plugin-otel", + "oc-plugin": [ + "server" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/DEVtheOPS/opencode-plugin-otel.git" + }, + "scripts": { + "build": "bun build src/index.ts --outdir=./dist --target=node && tsc -p tsconfig.build.json", + "check:jsdoc-coverage": "bun scripts/check-jsdoc-coverage.mjs", + "lint": "bun --bun eslint .", + "typecheck": "tsc --noEmit", + "prepack": "bun run build" + }, + "type": "module", + "version": "1.0.0" +} diff --git a/src/config.ts b/src/config.ts index 887865f..f8882b7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,9 @@ export type PluginConfig = { otlpHeadersHelper: string | undefined resourceAttributes: string | undefined spanAttributes: string | undefined + metricAttributes: string | undefined + excludeMetricAttributes: Set + costUsageScale: number traceparent: string | undefined tracestate: string | undefined metricsTemporality: MetricsTemporality | undefined @@ -71,6 +74,9 @@ export type OtelPluginOptions = { otlpHeadersHelper?: string resourceAttributes?: string spanAttributes?: string + metricAttributes?: string + excludeMetricAttributes?: string[] + costUsageScale?: number traceparent?: string tracestate?: string metricsTemporality?: MetricsTemporality @@ -93,6 +99,10 @@ function pickPositiveInt(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined } +function pickPositiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined +} + function pickStringList(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined return value.filter((entry): entry is string => typeof entry === "string") @@ -119,6 +129,18 @@ export function parseEnvInt(key: string, fallback: number): number { return Number.isSafeInteger(n) ? n : fallback } +/** + * Parses a positive finite number (integer or decimal) from an environment variable. + * Returns `fallback` when absent, non-numeric, zero, negative, or non-finite. + */ +export function parseEnvPositiveNumber(key: string, fallback: number): number { + const raw = process.env[key] + if (!raw) return fallback + if (!/^\d+(\.\d+)?$|^\.\d+$/.test(raw)) return fallback + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : fallback +} + /** Returns `true` when the environment variable is present and non-empty. */ function hasNonEmptyEnv(key: string): boolean { return !!process.env[key] @@ -155,6 +177,7 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { const otlpHeadersHelper = pickString(resolvedOptions.otlpHeadersHelper) ?? process.env["OPENCODE_OTLP_HEADERS_HELPER"] const resourceAttributes = pickString(resolvedOptions.resourceAttributes) ?? process.env["OPENCODE_RESOURCE_ATTRIBUTES"] const spanAttributes = pickString(resolvedOptions.spanAttributes) ?? process.env["OPENCODE_SPAN_ATTRIBUTES"] + const metricAttributes = pickString(resolvedOptions.metricAttributes) ?? process.env["OPENCODE_METRIC_ATTRIBUTES"] const traceparent = pickString(resolvedOptions.traceparent) ?? process.env["OPENCODE_TRACEPARENT"] const tracestate = pickString(resolvedOptions.tracestate) ?? process.env["OPENCODE_TRACESTATE"] const optionMetricsTemporality = pickMetricsTemporality(resolvedOptions.metricsTemporality) @@ -194,6 +217,29 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { : splitList(process.env["OPENCODE_TRACE_PROPAGATION_PROVIDERS"]), ) + const optionExcludeMetricAttributes = pickStringList(resolvedOptions.excludeMetricAttributes) + const excludeMetricAttributes = new Set( + optionExcludeMetricAttributes + ? normalizeList(optionExcludeMetricAttributes) + : splitList(process.env["OPENCODE_EXCLUDE_METRICS_ATTRIBUTES"]), + ) + + const optionCostUsageScale = pickPositiveNumber(resolvedOptions.costUsageScale) + const rawCostUsageScale = process.env["OPENCODE_COST_USAGE_SCALE"] + const envCostUsageScale = parseEnvPositiveNumber("OPENCODE_COST_USAGE_SCALE", 1) + const costUsageScale = optionCostUsageScale ?? envCostUsageScale + if ( + optionCostUsageScale === undefined + && rawCostUsageScale + && envCostUsageScale === 1 + && Number(rawCostUsageScale) !== 1 + ) { + console.warn( + `[opencode-plugin-otel] Invalid OPENCODE_COST_USAGE_SCALE="${rawCostUsageScale}". ` + + `Expected a positive number (e.g. 1000000). Value ignored.`, + ) + } + return { enabled: pickBoolean(resolvedOptions.enabled) ?? hasNonEmptyEnv("OPENCODE_ENABLE_TELEMETRY"), logsEnabled: pickBoolean(resolvedOptions.logsEnabled) ?? !hasNonEmptyEnv("OPENCODE_DISABLE_LOGS"), @@ -209,6 +255,9 @@ export function loadConfig(options: OtelPluginOptions = {}): PluginConfig { otlpHeadersHelper, resourceAttributes, spanAttributes, + metricAttributes, + excludeMetricAttributes, + costUsageScale, traceparent, tracestate, metricsTemporality, diff --git a/src/handlers/chat-headers.ts b/src/handlers/chat-headers.ts index f544a5b..eafd147 100644 --- a/src/handlers/chat-headers.ts +++ b/src/handlers/chat-headers.ts @@ -87,6 +87,7 @@ export async function handleModelRequest(event: ModelRequestEvent, ctx: HandlerC [LLM_PROVIDER]: providerID, "gen_ai.provider.name": genAiProviderName(providerID), ...ctx.commonAttrs, + ...ctx.spanAttributes, }, }, resolveRunContext(event.sessionID, ctx), diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 3d9c65a..b22ec88 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -211,6 +211,7 @@ export function handleExecutionStarted(e: EventOf<"session.execution.started">, } : {}), ...ctx.commonAttrs, + ...ctx.spanAttributes, }, }, parentCtx, diff --git a/src/handlers/step.ts b/src/handlers/step.ts index b87d11b..7f629bd 100644 --- a/src/handlers/step.ts +++ b/src/handlers/step.ts @@ -103,6 +103,7 @@ export function handleStepStarted(e: EventOf<"session.step.started">, ctx: Handl } : {}), ...ctx.commonAttrs, + ...ctx.spanAttributes, } const active = ctx.tracing.activeLlm.get(d.sessionID) const provisional = ctx.tracing.provisionalLlm.get(d.sessionID) @@ -304,7 +305,7 @@ function recordUsageMetrics( tokenCounter.add(tokens.cache.write, { ...base, type: "cacheCreation" }) } if (cost !== undefined && isMetricEnabled("cost.usage", ctx)) { - ctx.instruments.costCounter.add(cost, base) + ctx.instruments.costCounter.add(cost * ctx.costUsageScale, base) } if (tokens && isMetricEnabled("cache.count", ctx)) { if (tokens.cache.read > 0) ctx.instruments.cacheCounter.add(1, { ...base, type: "cacheRead" }) diff --git a/src/handlers/tool.ts b/src/handlers/tool.ts index 07fe8f6..64242d2 100644 --- a/src/handlers/tool.ts +++ b/src/handlers/tool.ts @@ -28,6 +28,9 @@ import { const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND const GIT_COMMIT_RE = /\bgit\s+commit(?![-\w])/ const SHELL_TOOL_RE = /(^|\.)(bash|shell)$/i +/** Failure signatures that playwright tools print to output while still reporting success. */ +const PLAYWRIGHT_FALSE_SUCCESS_RE + = /missing (system )?dependenc|executable doesn'?t exist|browser (was )?not (found|installed)|failed to launch|target closed|net::ERR_/i type ToolContent = { type: "text"; text: string } | { type: "file"; uri: string; mime: string; name?: string } @@ -70,6 +73,7 @@ export function handleToolCalled(e: EventOf<"session.tool.called">, ctx: Handler [TOOL_ID]: d.id, [TOOL_NAME]: meta.tool, ...ctx.commonAttrs, + ...ctx.spanAttributes, }, }, resolveStepContext(d.sessionID, d.assistantMessageID, ctx), @@ -119,6 +123,7 @@ function startSubagentSpan(callID: string, ctx: HandlerContext) { [AGENT_NAME]: agentName, "agent.type": agentType, ...ctx.commonAttrs, + ...ctx.spanAttributes, }, }, resolveStepContext(meta.sessionID, meta.assistantMessageID, ctx), @@ -168,6 +173,10 @@ function finishTool( const meta = ctx.tracing.toolMeta.get(callID) ctx.tracing.toolMeta.delete(callID) const tool = meta?.tool ?? "unknown" + if (success && tool.startsWith("playwright") && PLAYWRIGHT_FALSE_SUCCESS_RE.test(output)) { + success = false + error = `tool reported success but output contains a failure: ${output.slice(0, 200)}` + } const observedExecution = meta?.executionStarted === true const start = observedExecution ? meta.startMs : endMs const durationMs = Math.max(0, endMs - start) diff --git a/src/otel.ts b/src/otel.ts index 4963e62..f5a24a6 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -1,5 +1,6 @@ import { logs } from "@opentelemetry/api-logs" import { metrics, trace } from "@opentelemetry/api" +import type { Counter, Histogram, Attributes, MetricOptions, Context } from "@opentelemetry/api" import { LoggerProvider, BatchLogRecordProcessor } from "@opentelemetry/sdk-logs" import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics" import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" @@ -173,62 +174,106 @@ export async function setupOtel( return { meterProvider, loggerProvider, tracerProvider } } -/** Creates all metric instruments using the global `MeterProvider`. Metric names are prefixed with `prefix`. */ -export function createInstruments(prefix: string): Instruments { +/** + * Creates all metric instruments using the global `MeterProvider`. Metric names are + * prefixed with `prefix`. When `metricAttributes` is non-empty, every instrument is + * wrapped so those attributes are merged into each recorded data point (metrics only — + * spans and logs are unaffected). Sourced from `OPENCODE_METRIC_ATTRIBUTES`. + * Keys listed in `excludeMetricAttributes` (from `OPENCODE_EXCLUDE_METRICS_ATTRIBUTES`) are + * stripped from each data point last, so exclusion always wins over `metricAttributes`. + */ +export function createInstruments( + prefix: string, + metricAttributes: Record = {}, + excludeMetricAttributes: Set = new Set(), + costUsageScale: number = 1, +): Instruments { const meter = metrics.getMeter("com.opencode") + const hasExtra = Object.keys(metricAttributes).length > 0 + const hasExclude = excludeMetricAttributes.size > 0 + const needsWrap = hasExtra || hasExclude + const prepare = (attrs?: Attributes): Attributes | undefined => { + if (!needsWrap) return attrs + const result: Attributes = { ...(attrs ?? {}) } + if (hasExtra) { + Object.assign(result, metricAttributes) + } + // Exclusion is applied last so it always wins, regardless of whether the key + // came from the caller's attributes or from `metricAttributes`. + if (hasExclude) { + for (const key of excludeMetricAttributes) { + delete result[key] + } + } + return Object.keys(result).length > 0 ? result : undefined + } + + const counter = (name: string, options: MetricOptions): Counter => { + const instrument = meter.createCounter(name, options) + if (!needsWrap) return instrument + return { add: (value: number, attrs?: Attributes, context?: Context) => instrument.add(value, prepare(attrs), context) } as Counter + } + const histogram = (name: string, options: MetricOptions): Histogram => { + const instrument = meter.createHistogram(name, options) + if (!needsWrap) return instrument + return { record: (value: number, attrs?: Attributes, context?: Context) => instrument.record(value, prepare(attrs), context) } as Histogram + } + return { - sessionCounter: meter.createCounter(`${prefix}session.count`, { + sessionCounter: counter(`${prefix}session.count`, { unit: "{session}", description: "Count of opencode sessions started", }), - tokenCounter: meter.createCounter(`${prefix}token.usage`, { + tokenCounter: counter(`${prefix}token.usage`, { unit: "tokens", description: "Number of tokens used", }), - costCounter: meter.createCounter(`${prefix}cost.usage`, { - unit: "USD", - description: "Cost of the opencode session in USD", + costCounter: counter(`${prefix}cost.usage`, { + unit: costUsageScale === 1 ? "USD" : `USD/${costUsageScale}`, + description: costUsageScale === 1 + ? "Cost of the opencode session in USD" + : `Cost of the opencode session in USD, scaled by ${costUsageScale} (divide values by ${costUsageScale} for dollars). Set via OPENCODE_COST_USAGE_SCALE.`, }), - commitCounter: meter.createCounter(`${prefix}commit.count`, { + commitCounter: counter(`${prefix}commit.count`, { unit: "{commit}", description: "Number of git commits created", }), - toolDurationHistogram: meter.createHistogram(`${prefix}tool.duration`, { + toolDurationHistogram: histogram(`${prefix}tool.duration`, { unit: "ms", description: "Duration of tool executions in milliseconds", }), - cacheCounter: meter.createCounter(`${prefix}cache.count`, { + cacheCounter: counter(`${prefix}cache.count`, { unit: "{request}", description: "Token cache activity (cacheRead/cacheCreation) per completed LLM step", }), - sessionDurationHistogram: meter.createHistogram(`${prefix}session.duration`, { + sessionDurationHistogram: histogram(`${prefix}session.duration`, { unit: "ms", description: "Duration of a session from created to idle in milliseconds", }), - messageCounter: meter.createCounter(`${prefix}message.count`, { + messageCounter: counter(`${prefix}message.count`, { unit: "{message}", description: "Number of completed assistant messages per session", }), - sessionTokenHistogram: meter.createHistogram(`${prefix}session.token.total`, { + sessionTokenHistogram: histogram(`${prefix}session.token.total`, { unit: "tokens", description: "Total tokens consumed per session, recorded as a histogram when an execution ends", }), - sessionCostHistogram: meter.createHistogram(`${prefix}session.cost.total`, { + sessionCostHistogram: histogram(`${prefix}session.cost.total`, { unit: "USD", 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], }, }), - modelUsageCounter: meter.createCounter(`${prefix}model.usage`, { + modelUsageCounter: counter(`${prefix}model.usage`, { unit: "{request}", description: "Number of completed assistant messages per model and provider", }), - retryCounter: meter.createCounter(`${prefix}retry.count`, { + retryCounter: counter(`${prefix}retry.count`, { unit: "{retry}", description: "Number of API retries observed via session.retry.scheduled events", }), - subtaskCounter: meter.createCounter(`${prefix}subtask.count`, { + subtaskCounter: counter(`${prefix}subtask.count`, { unit: "{subtask}", description: "Number of sub-agent sessions observed via session.created with a parentID", }), diff --git a/src/plugin.ts b/src/plugin.ts index a416d61..dfd667c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -96,13 +96,18 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> await log("warn", "invalid OPENCODE_TRACEPARENT ignored", { traceparentLength: config.traceparent.length }) } + const spanAttributes = parseAttributePairs(config.spanAttributes) + const metricAttributes = parseAttributePairs(config.metricAttributes) + const hctx: HandlerContext = { log, emitLog, instruments: shared.instruments, - commonAttrs: parseAttributePairs(config.spanAttributes), + commonAttrs: {}, disabledMetrics: config.disabledMetrics, disabledTraces: config.disabledTraces, + spanAttributes, + costUsageScale: config.costUsageScale, tracer: shared.tracer, tracePrefix: config.metricPrefix, rootContext: remoteContext ? () => remoteContext : () => ROOT_CONTEXT, @@ -113,6 +118,21 @@ export async function setup(ctx: OpenCodeContext): Promise<() => Promise> 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 (Object.keys(spanAttributes).length > 0) { + await log("info", "custom span attributes applied", { keys: Object.keys(spanAttributes) }) + } + if (Object.keys(metricAttributes).length > 0) { + await log("info", "custom metric attributes applied", { keys: Object.keys(metricAttributes) }) + } + if (config.excludeMetricAttributes.size > 0) { + await log("info", "metric attributes excluded", { excluded: [...config.excludeMetricAttributes] }) + } + if (config.costUsageScale !== 1) { + await log("info", "cost.usage scaling enabled", { + costUsageScale: config.costUsageScale, + note: `cost.usage values are multiplied by ${config.costUsageScale}; divide by ${config.costUsageScale} in dashboards for dollars. session.cost.total is unaffected.`, + }) + } if (config.capturePromptInLogs) { await log("info", "prompt-in-logs capture enabled - full prompt text emitted in the `prompt` attribute of user_prompt log events") } diff --git a/src/state.ts b/src/state.ts index e3395c0..d87ac6f 100644 --- a/src/state.ts +++ b/src/state.ts @@ -2,7 +2,7 @@ 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 { parseAttributePairs, type PluginConfig } from "./config.ts" import type { SharedOtel, TracingState } from "./types.ts" const OTEL_KEY = "__opencode_plugin_otel_shared__" @@ -20,6 +20,7 @@ export function configKey(config: PluginConfig): string { ...config, disabledMetrics: [...config.disabledMetrics].sort(), disabledTraces: [...config.disabledTraces].sort(), + excludeMetricAttributes: [...config.excludeMetricAttributes].sort(), tracePropagationProviders: [...config.tracePropagationProviders].sort(), logLevel: undefined, }) @@ -66,7 +67,12 @@ export async function acquireSharedOtel(config: PluginConfig, version: string): ) const shared: SharedOtel = { providers, - instruments: createInstruments(config.metricPrefix), + instruments: createInstruments( + config.metricPrefix, + parseAttributePairs(config.metricAttributes), + config.excludeMetricAttributes, + config.costUsageScale, + ), logger: logs.getLogger("com.opencode"), tracer: trace.getTracer("com.opencode"), refs: 0, diff --git a/src/types.ts b/src/types.ts index 07468e0..e418518 100644 --- a/src/types.ts +++ b/src/types.ts @@ -182,6 +182,10 @@ export type HandlerContext = { commonAttrs: CommonAttrs disabledMetrics: Set disabledTraces: Set + /** Attributes attached to spans only, sourced from `OPENCODE_SPAN_ATTRIBUTES`. */ + spanAttributes: Record + /** Multiplier applied to USD cost values before they are recorded as the `cost.usage` counter. Default `1`. Does not affect `session.cost.total`, spans, or log events. */ + costUsageScale: number tracer: Tracer tracePrefix: string rootContext: () => Context diff --git a/tests/config.test.ts b/tests/config.test.ts index 7b6a8dc..02dee64 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,21 +1,33 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { parseAttributePairs, parseEnvInt, loadConfig, resolveHelperPath, resolveLogLevel, TRACE_TYPES } from "../src/config.ts" +import { parseEnvInt, parseEnvPositiveNumber, loadConfig, resolveHelperPath, resolveLogLevel, parseAttributePairs, TRACE_TYPES } from "../src/config.ts" describe("parseAttributePairs", () => { + test("returns an empty object when undefined", () => { + expect(parseAttributePairs(undefined)).toEqual({}) + }) + + test("returns an empty object for an empty string", () => { + expect(parseAttributePairs("")).toEqual({}) + }) + test("parses comma-separated key=value pairs", () => { - expect(parseAttributePairs("team=platform,env=prod")).toEqual({ team: "platform", env: "prod" }) + expect(parseAttributePairs("a=1,b=2")).toEqual({ a: "1", b: "2" }) + }) + + test("trims whitespace around keys and values", () => { + expect(parseAttributePairs(" team = platform , env = prod ")).toEqual({ team: "platform", env: "prod" }) }) - test("trims whitespace and keeps empty values", () => { - expect(parseAttributePairs(" team = platform , empty = ")).toEqual({ team: "platform", empty: "" }) + test("keeps only the first = as the separator", () => { + expect(parseAttributePairs("url=https://x.io/v1?a=b")).toEqual({ url: "https://x.io/v1?a=b" }) }) - test("uses only the first equals sign as the separator", () => { - expect(parseAttributePairs("auth=Bearer abc=123")).toEqual({ auth: "Bearer abc=123" }) + test("skips pairs without an = or with an empty key", () => { + expect(parseAttributePairs("good=1,nope,=orphan")).toEqual({ good: "1" }) }) - test("ignores malformed pairs", () => { - expect(parseAttributePairs("missingequals,=novalue,,valid=yes")).toEqual({ valid: "yes" }) + test("last value wins for duplicate keys", () => { + expect(parseAttributePairs("k=1,k=2")).toEqual({ k: "2" }) }) }) @@ -58,6 +70,50 @@ describe("parseEnvInt", () => { afterEach(() => { delete process.env["TEST_INT"] }) }) +describe("parseEnvPositiveNumber", () => { + afterEach(() => { delete process.env["TEST_NUM"] }) + + test("returns fallback when env var is unset", () => { + delete process.env["TEST_NUM"] + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1) + }) + + test("parses a positive integer", () => { + process.env["TEST_NUM"] = "1000000" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1000000) + }) + + test("parses a positive decimal", () => { + process.env["TEST_NUM"] = "1.5" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1.5) + }) + + test("parses a leading-dot decimal", () => { + process.env["TEST_NUM"] = ".25" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(0.25) + }) + + test("returns fallback for non-numeric value", () => { + process.env["TEST_NUM"] = "abc" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1) + }) + + test("returns fallback for zero", () => { + process.env["TEST_NUM"] = "0" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1) + }) + + test("returns fallback for negative value", () => { + process.env["TEST_NUM"] = "-5" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1) + }) + + test("returns fallback for trailing-garbage value", () => { + process.env["TEST_NUM"] = "1000x" + expect(parseEnvPositiveNumber("TEST_NUM", 1)).toBe(1) + }) +}) + describe("loadConfig", () => { const vars = [ "OPENCODE_ENABLE_TELEMETRY", @@ -68,7 +124,6 @@ describe("loadConfig", () => { "OPENCODE_OTLP_HEADERS", "OPENCODE_OTLP_HEADERS_HELPER", "OPENCODE_RESOURCE_ATTRIBUTES", - "OPENCODE_SPAN_ATTRIBUTES", "OPENCODE_TRACEPARENT", "OPENCODE_TRACESTATE", "OPENCODE_OTLP_METRICS_TEMPORALITY", @@ -77,6 +132,10 @@ describe("loadConfig", () => { "OPENCODE_CAPTURE_PROMPT_IN_LOGS", "OPENCODE_CAPTURE_MODEL_CONTEXT", "OPENCODE_DISABLE_TRACES", + "OPENCODE_SPAN_ATTRIBUTES", + "OPENCODE_METRIC_ATTRIBUTES", + "OPENCODE_EXCLUDE_METRICS_ATTRIBUTES", + "OPENCODE_COST_USAGE_SCALE", "OPENCODE_TRACE_PROPAGATION_PROVIDERS", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_RESOURCE_ATTRIBUTES", @@ -95,6 +154,86 @@ describe("loadConfig", () => { expect(cfg.protocol).toBe("grpc") expect(cfg.metricsInterval).toBe(60000) expect(cfg.logsInterval).toBe(5000) + expect(cfg.spanAttributes).toBeUndefined() + expect(cfg.metricAttributes).toBeUndefined() + expect(cfg.excludeMetricAttributes).toEqual(new Set()) + expect(cfg.costUsageScale).toBe(1) + }) + + test("reads OPENCODE_COST_USAGE_SCALE", () => { + process.env["OPENCODE_COST_USAGE_SCALE"] = "1000000" + expect(loadConfig().costUsageScale).toBe(1000000) + }) + + test("falls back to 1 for invalid OPENCODE_COST_USAGE_SCALE and warns", () => { + const warnings: string[] = [] + const origWarn = console.warn + console.warn = (...args: unknown[]) => warnings.push(String(args[0])) + try { + process.env["OPENCODE_COST_USAGE_SCALE"] = "nope" + const cfg = loadConfig() + expect(cfg.costUsageScale).toBe(1) + expect(warnings.length).toBe(1) + expect(warnings[0]).toContain("nope") + } finally { + console.warn = origWarn + } + }) + + test("does not warn when OPENCODE_COST_USAGE_SCALE is the literal value '1'", () => { + const warnings: string[] = [] + const origWarn = console.warn + console.warn = (...args: unknown[]) => warnings.push(String(args[0])) + try { + process.env["OPENCODE_COST_USAGE_SCALE"] = "1" + const cfg = loadConfig() + expect(cfg.costUsageScale).toBe(1) + expect(warnings.length).toBe(0) + } finally { + console.warn = origWarn + } + }) + + test("falls back to 1 for zero or negative OPENCODE_COST_USAGE_SCALE", () => { + const origWarn = console.warn + console.warn = () => {} + try { + process.env["OPENCODE_COST_USAGE_SCALE"] = "0" + expect(loadConfig().costUsageScale).toBe(1) + process.env["OPENCODE_COST_USAGE_SCALE"] = "-5" + expect(loadConfig().costUsageScale).toBe(1) + } finally { + console.warn = origWarn + } + }) + + test("reads OPENCODE_SPAN_ATTRIBUTES into spanAttributes", () => { + process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform,env=prod" + expect(loadConfig().spanAttributes).toBe("team=platform,env=prod") + }) + + test("reads OPENCODE_METRIC_ATTRIBUTES into metricAttributes", () => { + process.env["OPENCODE_METRIC_ATTRIBUTES"] = "deployment.environment=production" + expect(loadConfig().metricAttributes).toBe("deployment.environment=production") + }) + + test("parses OPENCODE_EXCLUDE_METRICS_ATTRIBUTES into excludeMetricAttributes", () => { + process.env["OPENCODE_EXCLUDE_METRICS_ATTRIBUTES"] = "session.id, model" + expect(loadConfig().excludeMetricAttributes).toEqual(new Set(["session.id", "model"])) + }) + + test("spanAttributes and metricAttributes are surfaced independently", () => { + process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform" + process.env["OPENCODE_METRIC_ATTRIBUTES"] = "env=prod" + const cfg = loadConfig() + expect(cfg.spanAttributes).toBe("team=platform") + expect(cfg.metricAttributes).toBe("env=prod") + }) + + test("does not copy OPENCODE_SPAN_ATTRIBUTES into OTEL_RESOURCE_ATTRIBUTES", () => { + process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform" + loadConfig() + expect(process.env["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined() }) test("enabled when OPENCODE_ENABLE_TELEMETRY is set", () => { @@ -173,11 +312,6 @@ describe("loadConfig", () => { expect(cfg.tracestate).toBe("vendor=value") }) - test("reads OPENCODE_SPAN_ATTRIBUTES", () => { - process.env["OPENCODE_SPAN_ATTRIBUTES"] = "team=platform,env=prod" - 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" diff --git a/tests/handlers/step.test.ts b/tests/handlers/step.test.ts index ee36269..fecc22f 100644 --- a/tests/handlers/step.test.ts +++ b/tests/handlers/step.test.ts @@ -59,6 +59,33 @@ describe("handleStepEnded", () => { expect(llm.attributes["llm.output_messages"]).toBe('[{"role":"assistant","content":"hello\\nworld"}]') }) + test("scales cost.usage by costUsageScale while spans and logs keep raw USD", () => { + const { ctx, tracer, counters, logger } = makeCtx("proj_test", [], [], true, {}, { costUsageScale: 1_000_000 }) + seedSession(ctx) + handleStepStarted(stepStarted(), ctx) + handleStepEnded( + evt("session.step.ended", { sessionID: "ses_1", assistantMessageID: "msg_1", finish: "stop", cost: 0.023, tokens: tokens(10, 5, 0, 0, 0) }, 2000), + ctx, + ) + expect(counters.cost.calls[0]!.value).toBeCloseTo(23_000) + expect(tracer.spans.find((s) => s.name === "opencode.llm")!.attributes["cost_usd"]).toBe(0.023) + expect(logger.records.at(-1)!.attributes?.["cost_usd"]).toBe(0.023) + }) + + test("adds span attributes to the LLM span only", () => { + const { ctx, tracer, counters, logger } = makeCtx("proj_test", [], [], true, {}, { spanAttributes: { team: "platform" } }) + 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, 0, 0, 0) }, 2000), + ctx, + ) + expect(tracer.spans.find((s) => s.name === "opencode.llm")!.attributes["team"]).toBe("platform") + expect(tracer.spans.find((s) => s.name === "opencode.session")!.attributes["team"]).toBe("platform") + expect(counters.cost.calls[0]!.attrs["team"]).toBeUndefined() + expect(logger.records.at(-1)!.attributes?.["team"]).toBeUndefined() + }) + test("counts a message once across multiple steps", () => { const { ctx, counters } = makeCtx() seedSession(ctx) diff --git a/tests/handlers/tool.test.ts b/tests/handlers/tool.test.ts index b7c10b3..b7c84d1 100644 --- a/tests/handlers/tool.test.ts +++ b/tests/handlers/tool.test.ts @@ -192,3 +192,45 @@ describe("handleToolFailed", () => { expect(histograms.tool.calls[0]!.attrs["success"]).toBe(false) }) }) + +describe("playwright success cross-check", () => { + test("marks a playwright tool failed when its output carries a failure signature", () => { + const { ctx, tracer, histograms, logger } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted("call_1", "playwright_browser_navigate"), ctx) + handleToolCalled(called("call_1", { url: "https://example.com" }, local), ctx) + handleToolSuccess(succeeded("call_1", "Error: browserType.launch: Executable doesn't exist at /ms-playwright", local, 1200), ctx) + const span = tracer.spans.find((s) => s.name === "opencode.tool.playwright_browser_navigate")! + expect(span.status.code).toBe(SpanStatusCode.ERROR) + expect(span.attributes["tool.success"]).toBe(false) + expect(String(span.attributes["tool.error"])).toContain("tool reported success but output contains a failure") + expect(histograms.tool.calls[0]!.attrs["success"]).toBe(false) + expect(logger.records.at(-1)!.attributes?.["success"]).toBe(false) + }) + + test("keeps a genuine playwright success and leaves other tools alone", () => { + const { ctx, tracer } = makeCtx() + seed(ctx) + handleToolInputStarted(inputStarted("call_1", "playwright_browser_navigate"), ctx) + handleToolCalled(called("call_1", {}, local), ctx) + handleToolSuccess(succeeded("call_1", "Navigated to https://example.com", local), ctx) + handleToolInputStarted(inputStarted("call_2", "bash"), ctx) + handleToolCalled(called("call_2", {}, local), ctx) + handleToolSuccess(succeeded("call_2", "net::ERR_CONNECTION_REFUSED", local), ctx) + expect(tracer.spans.find((s) => s.name === "opencode.tool.playwright_browser_navigate")!.status.code).toBe(SpanStatusCode.OK) + expect(tracer.spans.find((s) => s.name === "opencode.tool.bash")!.status.code).toBe(SpanStatusCode.OK) + }) +}) + +describe("span attributes", () => { + test("are added to tool spans but not to tool_result logs or tool.duration", () => { + const { ctx, tracer, histograms, logger } = makeCtx("proj_test", [], [], true, {}, { spanAttributes: { team: "platform" } }) + seed(ctx) + handleToolInputStarted(inputStarted(), ctx) + handleToolCalled(called("call_1", { command: "ls" }, local), ctx) + handleToolSuccess(succeeded("call_1", "ok", local, 1100), ctx) + expect(tracer.spans.find((s) => s.name === "opencode.tool.bash")!.attributes["team"]).toBe("platform") + expect(histograms.tool.calls[0]!.attrs["team"]).toBeUndefined() + expect(logger.records.at(-1)!.attributes?.["team"]).toBeUndefined() + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index 09175ee..ce34e32 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -194,12 +194,18 @@ export type MockContext = { tracer: SpyTracer } +export type MakeCtxOverrides = { + costUsageScale?: number + spanAttributes?: Record +} + export function makeCtx( projectID = "proj_test", disabledMetrics: string[] = [], disabledTraces: string[] = [], logsEnabled = true, extraCommonAttrs: Record = {}, + overrides: MakeCtxOverrides = {}, ): MockContext { const session = makeCounter() const token = makeCounter() @@ -244,6 +250,8 @@ export function makeCtx( commonAttrs: { "project.id": projectID, ...extraCommonAttrs }, disabledMetrics: new Set(disabledMetrics), disabledTraces: new Set(disabledTraces), + spanAttributes: overrides.spanAttributes ?? {}, + costUsageScale: overrides.costUsageScale ?? 1, tracer: tracer as unknown as Tracer, tracePrefix: "opencode.", rootContext: () => ROOT_CONTEXT, diff --git a/tests/otel.test.ts b/tests/otel.test.ts index 7d7b305..27ff00f 100644 --- a/tests/otel.test.ts +++ b/tests/otel.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect, afterEach } from "bun:test" -import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-grpc" +import { metrics, type MeterProvider } from "@opentelemetry/api" +import { createInstruments } from "../src/otel.ts" import { OTLPLogExporter as OTLPHttpLogExporter } from "@opentelemetry/exporter-logs-otlp-http" 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" import { OTLPTraceExporter as OTLPProtoTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto" import { buildResource, forceFlushOtel, setupOtel, type OtelProviders } from "../src/otel.ts" @@ -72,12 +71,6 @@ describe("buildResource", () => { expect(resource.attributes["team"]).toBe("platform") }) - test("resource attribute values may contain equals signs", () => { - process.env["OTEL_RESOURCE_ATTRIBUTES"] = "auth=Bearer abc=123" - const resource = buildResource("0.0.1") - expect(resource.attributes["auth"]).toBe("Bearer abc=123") - }) - test("env resource attributes override defaults", () => { process.env["OTEL_RESOURCE_ATTRIBUTES"] = "service.name=my-override" const resource = buildResource("0.0.1") @@ -112,15 +105,6 @@ describe("setupOtel", () => { expect(exporters.trace).toBeInstanceOf(OTLPProtoTraceExporter) }) - test("uses gRPC exporters for grpc", async () => { - providers = await setupOtel("http://collector:4317", "grpc", 60000, 5000, "1.2.3") - const exporters = exportersOf(providers) - - expect(exporters.metric).toBeInstanceOf(OTLPMetricExporter) - expect(exporters.log).toBeInstanceOf(OTLPLogExporter) - expect(exporters.trace).toBeInstanceOf(OTLPTraceExporter) - }) - test("uses JSON HTTP exporters for http/json", async () => { providers = await setupOtel("http://collector:4318", "http/json", 60000, 5000, "1.2.3") const exporters = exportersOf(providers) @@ -178,6 +162,113 @@ describe("setupOtel", () => { }) }) +describe("createInstruments metric attributes", () => { + type RecordedCall = { value: number; attrs: Record | undefined } + + function installFakeMeter() { + const calls: RecordedCall[] = [] + const instrument = { + add: (value: number, attrs?: Record) => calls.push({ value, attrs }), + record: (value: number, attrs?: Record) => calls.push({ value, attrs }), + } + const meter = { + createCounter: () => instrument, + createHistogram: () => instrument, + createGauge: () => instrument, + } + metrics.disable() + metrics.setGlobalMeterProvider({ getMeter: () => meter } as unknown as MeterProvider) + return calls + } + + afterEach(() => metrics.disable()) + + test("merges metric attributes into every recorded data point", () => { + const calls = installFakeMeter() + const instruments = createInstruments("opencode.", { team: "appgen", "deployment.environment": "production" }) + instruments.tokenCounter.add(10, { "session.id": "s1", type: "input" }) + instruments.toolDurationHistogram.record(5, { tool_name: "bash" }) + expect(calls[0]!.attrs).toEqual({ "session.id": "s1", type: "input", team: "appgen", "deployment.environment": "production" }) + expect(calls[1]!.attrs).toEqual({ tool_name: "bash", team: "appgen", "deployment.environment": "production" }) + }) + + test("leaves attributes untouched when no metric attributes are configured", () => { + const calls = installFakeMeter() + const instruments = createInstruments("opencode.", {}) + instruments.costCounter.add(0.5, { model: "claude" }) + expect(calls[0]!.attrs).toEqual({ model: "claude" }) + }) + + test("metric attributes override caller-supplied keys of the same name", () => { + const calls = installFakeMeter() + const instruments = createInstruments("opencode.", { env: "prod" }) + instruments.sessionCounter.add(1, { env: "dev" }) + expect(calls[0]!.attrs).toEqual({ env: "prod" }) + }) + + test("strips excluded metric attributes from recorded data points", () => { + const calls = installFakeMeter() + const instruments = createInstruments("opencode.", {}, new Set(["session.id"])) + instruments.tokenCounter.add(10, { "session.id": "s1", type: "input", model: "claude" }) + expect(calls[0]!.attrs).toEqual({ type: "input", model: "claude" }) + }) + + test("exclusion wins over a configured metric attribute of the same key", () => { + const calls = installFakeMeter() + const instruments = createInstruments("opencode.", { env: "prod", "session.id": "static" }, new Set(["session.id"])) + instruments.messageCounter.add(1, { "session.id": "s1", model: "claude" }) + expect(calls[0]!.attrs).toEqual({ model: "claude", env: "prod" }) + }) +}) + +describe("createInstruments cost usage scale", () => { + type Captured = { name: string; options: { unit?: string; description?: string } } + + function installCapturingMeter(): Captured[] { + const captured: Captured[] = [] + const instrument = { add: () => {}, record: () => {} } + const make = (name: string, options: { unit?: string; description?: string }) => { + captured.push({ name, options }) + return instrument + } + const meter = { + createCounter: make, + createHistogram: make, + createGauge: make, + } + metrics.disable() + metrics.setGlobalMeterProvider({ getMeter: () => meter } as unknown as MeterProvider) + return captured + } + + afterEach(() => metrics.disable()) + + test("declares USD units when costUsageScale is 1 (default)", () => { + const captured = installCapturingMeter() + createInstruments("opencode.") + const cost = captured.find(c => c.name === "opencode.cost.usage")! + const sessionCost = captured.find(c => c.name === "opencode.session.cost.total")! + expect(cost.options.unit).toBe("USD") + expect(sessionCost.options.unit).toBe("USD") + }) + + test("annotates cost.usage unit and description when costUsageScale is set", () => { + const captured = installCapturingMeter() + createInstruments("opencode.", {}, new Set(), 1_000_000) + const cost = captured.find(c => c.name === "opencode.cost.usage")! + expect(cost.options.unit).toBe("USD/1000000") + expect(cost.options.description).toContain("1000000") + }) + + test("session.cost.total unit and description are not affected by costUsageScale", () => { + const captured = installCapturingMeter() + createInstruments("opencode.", {}, new Set(), 1_000_000) + const sessionCost = captured.find(c => c.name === "opencode.session.cost.total")! + expect(sessionCost.options.unit).toBe("USD") + expect(sessionCost.options.description).not.toContain("1000000") + }) +}) + describe("forceFlushOtel", () => { test("flushes metrics, logs, and traces", async () => { const calls: string[] = [] diff --git a/tests/state.test.ts b/tests/state.test.ts index b1e7514..33557f6 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -27,6 +27,15 @@ describe("multi-location telemetry", () => { } }) + test("treats differing metric attribute settings as a conflicting configuration", () => { + const base = loadConfig({ enabled: true }) + expect(configKey(loadConfig({ enabled: true, excludeMetricAttributes: ["session.id"] }))).not.toBe(configKey(base)) + expect(configKey(loadConfig({ enabled: true, metricAttributes: "env=prod" }))).not.toBe(configKey(base)) + expect(configKey(loadConfig({ enabled: true, costUsageScale: 1000 }))).not.toBe(configKey(base)) + expect(configKey(loadConfig({ enabled: true, excludeMetricAttributes: ["a", "b"] }))) + .toBe(configKey(loadConfig({ enabled: true, excludeMetricAttributes: ["b", "a"] }))) + }) + test("attributes observed sessions to their own projects", async () => { const { ctx } = makeCtx() const base: HandlerContext = { ...ctx, commonAttrs: { team: "platform" } }