From af7b19575d1010a0f5e1a63b1790d1e52d2d42b6 Mon Sep 17 00:00:00 2001 From: Andrew Lloyd Date: Tue, 13 Jan 2026 20:56:22 -0400 Subject: [PATCH] feat(analytics): add Prometheus metrics plugin Adds @opencode-ai/analytics plugin that tracks: - Tool execution counts and timing via tool.execute.before/after hooks - Session activity via event hooks - Prometheus exposition format output Metrics include counters, histograms, and gauges for observability. --- packages/analytics/README.md | 76 ++++++++++ packages/analytics/package.json | 38 +++++ packages/analytics/src/index.ts | 249 +++++++++++++++++++++++++++++++ packages/analytics/tsconfig.json | 15 ++ 4 files changed, 378 insertions(+) create mode 100644 packages/analytics/README.md create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/index.ts create mode 100644 packages/analytics/tsconfig.json diff --git a/packages/analytics/README.md b/packages/analytics/README.md new file mode 100644 index 00000000000..dfa575d5ca0 --- /dev/null +++ b/packages/analytics/README.md @@ -0,0 +1,76 @@ +# @opencode-ai/analytics + +Prometheus-compatible metrics plugin for OpenCode. Automatically tracks tool usage, execution timing, and session activity. + +## Installation + +```bash +# Add to your opencode config +{ + "plugin": ["@opencode-ai/analytics"] +} +``` + +## Metrics Exposed + +### Counters +- `opencode_tool_calls_total{tool, status}` - Total tool calls by tool name and status (success/error) +- `opencode_messages_total{type}` - Total messages by type +- `opencode_tokens_total{type, model}` - Token usage by type and model +- `opencode_errors_total{type}` - Total errors by type + +### Histograms +- `opencode_tool_duration_ms{tool}` - Tool execution duration in milliseconds + +### Gauges +- `opencode_sessions_active` - Number of active sessions +- `opencode_tool_calls_inflight{tool}` - Currently executing tool calls + +## Usage + +Once enabled, metrics are collected automatically via the `tool.execute.before` and `tool.execute.after` hooks. + +### Get Metrics + +Use the `metrics` tool to retrieve current metrics: + +``` +/metrics +``` + +Returns Prometheus exposition format: + +``` +# HELP opencode_tool_calls_total Total number of tool calls +# TYPE opencode_tool_calls_total counter +opencode_tool_calls_total{tool="Read",status="success"} 42 +opencode_tool_calls_total{tool="Write",status="success"} 15 + +# HELP opencode_tool_duration_ms Tool execution duration in milliseconds +# TYPE opencode_tool_duration_ms histogram +opencode_tool_duration_ms_bucket{tool="Read",le="100"} 38 +opencode_tool_duration_ms_bucket{tool="Read",le="500"} 42 +... +``` + +## Scraping with Prometheus + +To expose metrics for Prometheus scraping, you can create an HTTP endpoint that calls `formatMetrics()`: + +```typescript +import { formatMetrics } from "@opencode-ai/analytics" + +// In your server +app.get("/metrics", (req, res) => { + res.type("text/plain") + res.send(formatMetrics()) +}) +``` + +## Configuration + +No configuration required. The plugin automatically hooks into OpenCode's plugin system. + +## License + +MIT diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 00000000000..70411262d87 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,38 @@ +{ + "name": "@opencode-ai/analytics", + "version": "0.0.1", + "description": "Analytics and Prometheus metrics plugin for OpenCode", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target=node", + "dev": "bun build ./src/index.ts --outdir ./dist --target=node --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/sdk": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "bun": ">=1.0.0" + }, + "keywords": [ + "opencode", + "analytics", + "prometheus", + "metrics", + "telemetry" + ], + "license": "MIT" +} diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts new file mode 100644 index 00000000000..74ac36f63ae --- /dev/null +++ b/packages/analytics/src/index.ts @@ -0,0 +1,249 @@ +import type { Plugin, Hooks, PluginInput } from "@opencode-ai/plugin" + +interface Counter { + name: string + help: string + labels: string[] + values: Map +} + +interface Histogram { + name: string + help: string + labels: string[] + buckets: number[] + values: Map }> +} + +interface Gauge { + name: string + help: string + labels: string[] + values: Map +} + +const inflightCalls = new Map() +const counters: Map = new Map() +const histograms: Map = new Map() +const gauges: Map = new Map() +const DEFAULT_BUCKETS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000] + +function getLabelKey(labels: Record): string { + return Object.entries(labels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}="${v}"`) + .join(",") +} + +function incCounter(name: string, labels: Record = {}, value = 1): void { + const counter = counters.get(name) + if (!counter) return + const key = getLabelKey(labels) + counter.values.set(key, (counter.values.get(key) ?? 0) + value) +} + +function registerCounter(name: string, help: string, labels: string[] = []): void { + if (counters.has(name)) return + counters.set(name, { name, help, labels, values: new Map() }) +} + +function observeHistogram(name: string, labels: Record, value: number): void { + const histogram = histograms.get(name) + if (!histogram) return + const key = getLabelKey(labels) + + let entry = histogram.values.get(key) + if (!entry) { + entry = { count: 0, sum: 0, buckets: new Map() } + for (const bucket of histogram.buckets) { + entry.buckets.set(bucket, 0) + } + histogram.values.set(key, entry) + } + + entry.count++ + entry.sum += value + for (const bucket of histogram.buckets) { + if (value <= bucket) { + entry.buckets.set(bucket, (entry.buckets.get(bucket) ?? 0) + 1) + } + } +} + +function registerHistogram(name: string, help: string, labels: string[] = [], buckets = DEFAULT_BUCKETS): void { + if (histograms.has(name)) return + histograms.set(name, { name, help, labels, buckets, values: new Map() }) +} + +function setGauge(name: string, labels: Record, value: number): void { + const gauge = gauges.get(name) + if (!gauge) return + const key = getLabelKey(labels) + gauge.values.set(key, value) +} + +function incGauge(name: string, labels: Record = {}, value = 1): void { + const gauge = gauges.get(name) + if (!gauge) return + const key = getLabelKey(labels) + gauge.values.set(key, (gauge.values.get(key) ?? 0) + value) +} + +function decGauge(name: string, labels: Record = {}, value = 1): void { + incGauge(name, labels, -value) +} + +function registerGauge(name: string, help: string, labels: string[] = []): void { + if (gauges.has(name)) return + gauges.set(name, { name, help, labels, values: new Map() }) +} + +export function formatMetrics(): string { + const lines: string[] = [] + + for (const counter of counters.values()) { + lines.push(`# HELP ${counter.name} ${counter.help}`) + lines.push(`# TYPE ${counter.name} counter`) + for (const [labels, value] of counter.values) { + const labelStr = labels ? `{${labels}}` : "" + lines.push(`${counter.name}${labelStr} ${value}`) + } + } + + for (const histogram of histograms.values()) { + lines.push(`# HELP ${histogram.name} ${histogram.help}`) + lines.push(`# TYPE ${histogram.name} histogram`) + for (const [labels, entry] of histogram.values) { + const baseLabels = labels ? labels + "," : "" + for (const [bucket, count] of entry.buckets) { + lines.push(`${histogram.name}_bucket{${baseLabels}le="${bucket}"} ${count}`) + } + lines.push(`${histogram.name}_bucket{${baseLabels}le="+Inf"} ${entry.count}`) + lines.push(`${histogram.name}_sum{${labels || ""}} ${entry.sum}`) + lines.push(`${histogram.name}_count{${labels || ""}} ${entry.count}`) + } + } + + for (const gauge of gauges.values()) { + lines.push(`# HELP ${gauge.name} ${gauge.help}`) + lines.push(`# TYPE ${gauge.name} gauge`) + for (const [labels, value] of gauge.values) { + const labelStr = labels ? `{${labels}}` : "" + lines.push(`${gauge.name}${labelStr} ${value}`) + } + } + + return lines.join("\n") +} + +export function getMetricsJSON(): { + counters: Record> + histograms: Record> + gauges: Record> +} { + const result: ReturnType = { + counters: {}, + histograms: {}, + gauges: {}, + } + + for (const counter of counters.values()) { + result.counters[counter.name] = Object.fromEntries(counter.values) + } + + for (const histogram of histograms.values()) { + result.histograms[histogram.name] = {} + for (const [labels, entry] of histogram.values) { + result.histograms[histogram.name][labels || "_total"] = { + count: entry.count, + sum: entry.sum, + mean: entry.count > 0 ? entry.sum / entry.count : 0, + } + } + } + + for (const gauge of gauges.values()) { + result.gauges[gauge.name] = Object.fromEntries(gauge.values) + } + + return result +} + +function initMetrics(): void { + registerCounter("opencode_tool_calls_total", "Total number of tool calls", ["tool", "status"]) + registerHistogram("opencode_tool_duration_ms", "Tool execution duration in milliseconds", ["tool"]) + registerGauge("opencode_tool_calls_inflight", "Number of tool calls currently in progress", ["tool"]) + registerGauge("opencode_sessions_active", "Number of active sessions", []) + registerCounter("opencode_messages_total", "Total number of messages", ["type"]) + registerCounter("opencode_tokens_total", "Total tokens used", ["type", "model"]) + registerCounter("opencode_errors_total", "Total errors", ["type"]) +} + +export const analytics: Plugin = async (_input: PluginInput): Promise => { + initMetrics() + + const activeSessions = new Set() + + return { + async event({ event }) { + if (event.type === "session.created") { + const sessionId = (event.properties as { info: { id: string } }).info.id + activeSessions.add(sessionId) + setGauge("opencode_sessions_active", {}, activeSessions.size) + } else if (event.type === "session.deleted") { + const sessionId = (event.properties as { info: { id: string } }).info.id + activeSessions.delete(sessionId) + setGauge("opencode_sessions_active", {}, activeSessions.size) + } + }, + + async "chat.message"({ sessionID }, { message: _message }) { + incCounter("opencode_messages_total", { type: "user" }) + + if (!activeSessions.has(sessionID)) { + activeSessions.add(sessionID) + setGauge("opencode_sessions_active", {}, activeSessions.size) + } + }, + + async "tool.execute.before"({ tool, sessionID, callID }, { args: _args }) { + const key = `${sessionID}:${callID}` + inflightCalls.set(key, { tool, startTime: Date.now(), sessionID }) + incGauge("opencode_tool_calls_inflight", { tool }) + }, + + async "tool.execute.after"({ tool, sessionID, callID }, { title: _title, output: _output, metadata }) { + const key = `${sessionID}:${callID}` + const inflight = inflightCalls.get(key) + + if (inflight) { + const duration = Date.now() - inflight.startTime + observeHistogram("opencode_tool_duration_ms", { tool }, duration) + + const status = metadata?.error ? "error" : "success" + incCounter("opencode_tool_calls_total", { tool, status }) + decGauge("opencode_tool_calls_inflight", { tool }) + inflightCalls.delete(key) + } else { + const status = metadata?.error ? "error" : "success" + incCounter("opencode_tool_calls_total", { tool, status }) + } + + if (metadata?.error) { + incCounter("opencode_errors_total", { type: "tool_error" }) + } + }, + + tool: { + metrics: { + description: "Get Prometheus-compatible metrics for OpenCode usage", + args: {}, + async execute() { + return formatMetrics() + }, + }, + }, + } +} + +export default analytics diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 00000000000..ce024e6194d --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}