Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/analytics/README.md
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions packages/analytics/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
249 changes: 249 additions & 0 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { Plugin, Hooks, PluginInput } from "@opencode-ai/plugin"

interface Counter {
name: string
help: string
labels: string[]
values: Map<string, number>
}

interface Histogram {
name: string
help: string
labels: string[]
buckets: number[]
values: Map<string, { count: number; sum: number; buckets: Map<number, number> }>
}

interface Gauge {
name: string
help: string
labels: string[]
values: Map<string, number>
}

const inflightCalls = new Map<string, { tool: string; startTime: number; sessionID: string }>()
const counters: Map<string, Counter> = new Map()
const histograms: Map<string, Histogram> = new Map()
const gauges: Map<string, Gauge> = new Map()
const DEFAULT_BUCKETS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000]

function getLabelKey(labels: Record<string, string>): string {
return Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}="${v}"`)
.join(",")
}

function incCounter(name: string, labels: Record<string, string> = {}, 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<string, string>, 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<string, string>, 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<string, string> = {}, 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<string, string> = {}, 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<string, Record<string, number>>
histograms: Record<string, Record<string, { count: number; sum: number; mean: number }>>
gauges: Record<string, Record<string, number>>
} {
const result: ReturnType<typeof getMetricsJSON> = {
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<Hooks> => {
initMetrics()

const activeSessions = new Set<string>()

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
15 changes: 15 additions & 0 deletions packages/analytics/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}