Skip to content

Commit 2f77094

Browse files
feat(telemetry): add skill usage telemetry
1 parent 7153352 commit 2f77094

10 files changed

Lines changed: 628 additions & 10 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
4747
| `opencode.session.cost.total` | Histogram | Total cost per session in USD, recorded on idle |
4848
| `opencode.model.usage` | Counter | Messages per model and provider |
4949
| `opencode.retry.count` | Counter | API retries observed via `session.status` events |
50+
| `opencode.skill.count` | Counter | Skill invocations observed through command aliases or the native skill tool |
5051

5152
### Log events
5253

@@ -60,6 +61,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
6061
| `api_error` | Failed assistant message (error summary, duration) |
6162
| `tool_result` | Tool completed or errored (duration, success, output size) |
6263
| `tool_decision` | Permission prompt answered (accept/reject) |
64+
| `skill_invoked` | Skill invoked (includes skill name, invocation type, command or tool name, optional agent/subtask metadata, and command argument length when available) |
6365
| `commit` | Git commit detected |
6466

6567
## Installation
@@ -165,7 +167,7 @@ Disabling a metric only stops the counter/histogram from being incremented — t
165167
export OPENCODE_DISABLE_METRICS="retry.count"
166168

167169
# Disable multiple metrics
168-
export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count"
170+
export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count,skill.count"
169171

170172
# Disable the new per-session cumulative gauge while keeping the delta counter
171173
export OPENCODE_DISABLE_METRICS="lines_of_code.total"
@@ -176,7 +178,7 @@ export OPENCODE_DISABLE_METRICS="lines_of_code.total"
176178
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:
177179

178180
```bash
179-
export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count"
181+
export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.total,session.cost.total,model.usage,retry.count,message.count,skill.count"
180182
```
181183

182184
| Metric suffix | Why it's opencode-only |
@@ -188,6 +190,7 @@ export OPENCODE_DISABLE_METRICS="cache.count,session.duration,session.token.tota
188190
| `model.usage` | Per-model message counter — not emitted by Claude Code |
189191
| `retry.count` | API retry counter — not emitted by Claude Code |
190192
| `message.count` | Completed message counter — not emitted by Claude Code |
193+
| `skill.count` | OpenCode skill usage counter — not emitted by Claude Code |
191194

192195
### Disabling OTLP logs
193196

src/handlers/message.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from "@arizeai/openinference-semantic-conventions"
3030
import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled, isTraceEnabled } from "../util.ts"
3131
import type { HandlerContext } from "../types.ts"
32+
import { recordSkillInvocation, skillNameFromToolInput } from "./skill.ts"
3233

3334
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
3435
const LLM_FINISH_REASON = "llm.finish_reason"
@@ -251,8 +252,17 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
251252
if (part.type === "tool") {
252253
const toolPart = part as ToolPart
253254
const key = `${toolPart.sessionID}:${toolPart.callID}`
255+
const isSkillTool = toolPart.tool === "skill"
254256

255257
if (toolPart.state.status === "running") {
258+
if (isSkillTool && !ctx.pendingToolSpans.has(key)) {
259+
recordSkillInvocation({
260+
sessionID: toolPart.sessionID,
261+
skillName: skillNameFromToolInput(toolPart.state.input),
262+
invocationType: "tool",
263+
toolName: toolPart.tool,
264+
}, ctx)
265+
}
256266
const toolSpan = isTraceEnabled("tool", ctx)
257267
? (() => {
258268
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
@@ -294,6 +304,14 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
294304

295305
const pending = ctx.pendingToolSpans.get(key)
296306
ctx.pendingToolSpans.delete(key)
307+
if (isSkillTool && !pending) {
308+
recordSkillInvocation({
309+
sessionID: toolPart.sessionID,
310+
skillName: skillNameFromToolInput(toolPart.state.input),
311+
invocationType: "tool",
312+
toolName: toolPart.tool,
313+
}, ctx)
314+
}
297315
const start = pending?.startMs ?? toolPart.state.time.start
298316
const end = toolPart.state.time.end
299317
if (end === undefined) return

src/handlers/skill.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { isMetricEnabled } from "../util.ts"
2+
import type { HandlerContext, PluginLogger } from "../types.ts"
3+
4+
type SkillCommandInfo = {
5+
name: string
6+
description?: string
7+
agent?: string
8+
subtask?: boolean
9+
}
10+
11+
type RawCommand = {
12+
name?: unknown
13+
source?: unknown
14+
description?: unknown
15+
agent?: unknown
16+
subtask?: unknown
17+
}
18+
19+
type CommandListClient = {
20+
command?: {
21+
list(options?: { query?: { directory?: string; workspace?: string } }): Promise<{ data?: unknown; error?: unknown } | unknown>
22+
}
23+
}
24+
25+
type CommandExecuteInput = {
26+
command: string
27+
sessionID: string
28+
arguments: string
29+
}
30+
31+
type SkillInvocationInput = {
32+
sessionID: string
33+
skillName: string
34+
invocationType: "command" | "tool"
35+
commandName?: string
36+
toolName?: string
37+
argumentsLength?: number
38+
description?: string
39+
agent?: string
40+
subtask?: boolean
41+
}
42+
43+
type SkillCommandLookup = {
44+
ok: boolean
45+
commands: SkillCommandInfo[]
46+
}
47+
48+
type ParsedSkillCommands = {
49+
sourceAware: boolean
50+
commands: SkillCommandInfo[]
51+
}
52+
53+
function rawSkillCommand(command: RawCommand): SkillCommandInfo | undefined {
54+
if (command.source !== "skill" || typeof command.name !== "string") return undefined
55+
return {
56+
name: command.name,
57+
...(typeof command.description === "string" ? { description: command.description } : {}),
58+
...(typeof command.agent === "string" ? { agent: command.agent } : {}),
59+
...(typeof command.subtask === "boolean" ? { subtask: command.subtask } : {}),
60+
}
61+
}
62+
63+
function commandsFromPayload(payload: unknown): ParsedSkillCommands {
64+
const data = payload && typeof payload === "object" && "data" in payload
65+
? (payload as { data?: unknown }).data
66+
: payload
67+
const commands = data && typeof data === "object" && "data" in data
68+
? (data as { data?: unknown }).data
69+
: data
70+
if (!Array.isArray(commands)) return { sourceAware: false, commands: [] }
71+
return {
72+
sourceAware: commands.length === 0
73+
|| commands.some((command) => command && typeof command === "object" && "source" in command),
74+
commands: commands
75+
.map((command) => rawSkillCommand(command as RawCommand))
76+
.filter((command): command is SkillCommandInfo => !!command),
77+
}
78+
}
79+
80+
async function clientSkillCommands(client: CommandListClient, directory: string | undefined): Promise<SkillCommandLookup> {
81+
if (!client.command?.list) return { ok: false, commands: [] }
82+
const response = await client.command.list(directory ? { query: { directory } } : undefined)
83+
if (response && typeof response === "object" && "error" in response && (response as { error?: unknown }).error !== undefined) {
84+
return { ok: false, commands: [] }
85+
}
86+
const parsed = commandsFromPayload(response)
87+
return { ok: parsed.sourceAware, commands: parsed.commands }
88+
}
89+
90+
async function rawSkillCommands(serverUrl: URL, directory: string | undefined, path: "/command" | "/api/command"): Promise<SkillCommandLookup> {
91+
const url = new URL(path, serverUrl)
92+
if (directory) {
93+
if (path === "/api/command") {
94+
url.searchParams.set("location[directory]", directory)
95+
} else {
96+
url.searchParams.set("directory", directory)
97+
}
98+
}
99+
const controller = new AbortController()
100+
const timeout = setTimeout(() => controller.abort(), 1_000)
101+
try {
102+
const response = await fetch(url, { signal: controller.signal })
103+
if (!response.ok) return { ok: false, commands: [] }
104+
const parsed = commandsFromPayload(await response.json())
105+
return { ok: parsed.sourceAware, commands: parsed.commands }
106+
} finally {
107+
clearTimeout(timeout)
108+
}
109+
}
110+
111+
export function createSkillCommandResolver(input: {
112+
client: CommandListClient
113+
serverUrl: URL
114+
directory?: string
115+
log: PluginLogger
116+
}) {
117+
let lastRefresh = 0
118+
let refreshPromise: Promise<void> | undefined
119+
const skillCommands = new Map<string, SkillCommandInfo>()
120+
121+
const refresh = async (force = false) => {
122+
if (!force && Date.now() - lastRefresh < 30_000) return
123+
if (refreshPromise) return refreshPromise
124+
refreshPromise = (async () => {
125+
const next = new Map<string, SkillCommandInfo>()
126+
let catalogLoaded = false
127+
try {
128+
const lookup = await clientSkillCommands(input.client, input.directory)
129+
catalogLoaded = catalogLoaded || lookup.ok
130+
for (const command of lookup.commands) {
131+
next.set(command.name, command)
132+
}
133+
} catch (err) {
134+
await input.log("debug", "otel: command catalog lookup failed", {
135+
error: err instanceof Error ? err.message : String(err),
136+
})
137+
}
138+
if (next.size === 0) {
139+
try {
140+
const lookup = await rawSkillCommands(input.serverUrl, input.directory, "/command")
141+
catalogLoaded = catalogLoaded || lookup.ok
142+
for (const command of lookup.commands) {
143+
next.set(command.name, command)
144+
}
145+
} catch (err) {
146+
await input.log("debug", "otel: raw command catalog lookup failed", {
147+
error: err instanceof Error ? err.message : String(err),
148+
})
149+
}
150+
}
151+
if (next.size === 0) {
152+
try {
153+
const lookup = await rawSkillCommands(input.serverUrl, input.directory, "/api/command")
154+
catalogLoaded = catalogLoaded || lookup.ok
155+
for (const command of lookup.commands) {
156+
next.set(command.name, command)
157+
}
158+
} catch (err) {
159+
await input.log("debug", "otel: v2 command catalog lookup failed", {
160+
error: err instanceof Error ? err.message : String(err),
161+
})
162+
}
163+
}
164+
if (!catalogLoaded) {
165+
await input.log("debug", "otel: skill command catalog refresh skipped")
166+
return
167+
}
168+
skillCommands.clear()
169+
for (const [name, command] of next) skillCommands.set(name, command)
170+
lastRefresh = Date.now()
171+
await input.log("debug", "otel: skill command catalog refreshed", { count: skillCommands.size })
172+
})().finally(() => {
173+
refreshPromise = undefined
174+
})
175+
return refreshPromise
176+
}
177+
178+
return {
179+
refresh,
180+
resolve: async (command: string) => {
181+
let skill = skillCommands.get(command)
182+
if (skill) return skill
183+
await refresh(false)
184+
skill = skillCommands.get(command)
185+
return skill
186+
},
187+
}
188+
}
189+
190+
function stringProp(input: Record<string, unknown>, key: string): string | undefined {
191+
const value = input[key]
192+
return typeof value === "string" && value.trim() ? value : undefined
193+
}
194+
195+
/** Extracts a skill name from the native `skill` tool input without exposing raw input. */
196+
export function skillNameFromToolInput(input: unknown): string {
197+
if (typeof input === "string" && input.trim()) return input
198+
if (!input || typeof input !== "object") return "unknown"
199+
const record = input as Record<string, unknown>
200+
const direct = stringProp(record, "skill") ?? stringProp(record, "skillName") ?? stringProp(record, "name")
201+
if (direct) return direct
202+
const skill = record.skill
203+
if (skill && typeof skill === "object") {
204+
return stringProp(skill as Record<string, unknown>, "name") ?? "unknown"
205+
}
206+
return "unknown"
207+
}
208+
209+
/** Emits the shared skill usage metric and log event for command and native tool paths. */
210+
export function recordSkillInvocation(input: SkillInvocationInput, ctx: HandlerContext) {
211+
const attrs = {
212+
...ctx.commonAttrs,
213+
"session.id": input.sessionID,
214+
skill_name: input.skillName,
215+
invocation_type: input.invocationType,
216+
...(input.commandName ? { command_name: input.commandName } : {}),
217+
...(input.toolName ? { tool_name: input.toolName } : {}),
218+
...(input.agent ? { agent: input.agent } : {}),
219+
...(input.subtask !== undefined ? { subtask: input.subtask } : {}),
220+
}
221+
222+
if (isMetricEnabled("skill.count", ctx)) {
223+
ctx.instruments.skillCounter.add(1, attrs)
224+
}
225+
226+
ctx.emitLog({
227+
severityNumber: ctx.logSeverity.info,
228+
severityText: "INFO",
229+
timestamp: Date.now(),
230+
observedTimestamp: Date.now(),
231+
body: "skill_invoked",
232+
attributes: {
233+
"event.name": "skill_invoked",
234+
...attrs,
235+
...(input.argumentsLength !== undefined ? { arguments_length: input.argumentsLength } : {}),
236+
...(input.description ? { description: input.description } : {}),
237+
},
238+
})
239+
240+
return ctx.log("info", "otel: skill_invoked", {
241+
sessionID: input.sessionID,
242+
skill_name: input.skillName,
243+
invocation_type: input.invocationType,
244+
...(input.commandName ? { command_name: input.commandName } : {}),
245+
...(input.toolName ? { tool_name: input.toolName } : {}),
246+
})
247+
}
248+
249+
export async function handleCommandExecuteBefore(
250+
input: CommandExecuteInput,
251+
ctx: HandlerContext,
252+
resolveSkillCommand: (command: string) => Promise<SkillCommandInfo | undefined>,
253+
) {
254+
const skill = await resolveSkillCommand(input.command)
255+
if (!skill) return
256+
257+
return recordSkillInvocation({
258+
sessionID: input.sessionID,
259+
skillName: skill.name,
260+
invocationType: "command",
261+
commandName: input.command,
262+
argumentsLength: input.arguments.length,
263+
...(skill.description ? { description: skill.description } : {}),
264+
...(skill.agent ? { agent: skill.agent } : {}),
265+
...(skill.subtask !== undefined ? { subtask: skill.subtask } : {}),
266+
}, ctx)
267+
}

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess
2525
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
2626
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
2727
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
28+
import { createSkillCommandResolver, handleCommandExecuteBefore } from "./handlers/skill.ts"
2829

2930
const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown"
3031

@@ -33,7 +34,7 @@ const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown
3334
* Instruments metrics (sessions, tokens, cost, lines of code, commits, tool durations)
3435
* and structured log events. All instrumentation is gated on `OPENCODE_ENABLE_TELEMETRY`.
3536
*/
36-
export const OtelPlugin: Plugin = async ({ project, client, directory, worktree }) => {
37+
export const OtelPlugin: Plugin = async ({ project, client, directory, worktree, serverUrl }) => {
3738
const config = loadConfig()
3839
const otlpHeadersHelper = resolveHelperPath(config.otlpHeadersHelper, directory, worktree)
3940
let minLevel: Level = "info"
@@ -123,6 +124,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
123124
const ctx: HandlerContext = {
124125
log,
125126
emitLog,
127+
logSeverity: { info: SeverityNumber.INFO, error: SeverityNumber.ERROR },
126128
instruments,
127129
commonAttrs,
128130
pendingToolSpans,
@@ -139,6 +141,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
139141
sessionInputs,
140142
messageOutputs,
141143
}
144+
const skillCommands = createSkillCommandResolver({ client, serverUrl, directory, log })
145+
await skillCommands.refresh(true)
142146

143147
async function shutdown() {
144148
await Promise.allSettled([meterProvider.shutdown(), loggerProvider.shutdown(), tracerProvider.shutdown()])
@@ -217,6 +221,10 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
217221
})
218222
}),
219223

224+
"command.execute.before": safe("command.execute.before", async (input) => {
225+
await handleCommandExecuteBefore(input, ctx, skillCommands.resolve)
226+
}),
227+
220228
event: safe("event", async ({ event }) => {
221229
switch (event.type) {
222230
case "session.created":

0 commit comments

Comments
 (0)