Skip to content

Commit 7f78738

Browse files
sipercaiclaude
andcommitted
feat(claude-agent-sdk): capture gen_ai.skill.* on Skill load execute_tool span
Attach gen_ai.skill.name/id/description/version to the execute_tool span of the built-in Skill tool. Telemetry is bound to the ToolUseBlock(name="Skill") tool span (not the SKILL.md-injecting UserMessage TextBlock). - skill.name from ToolUseBlock.input.skill (frontmatter.name fallback) - skill.id = claude:project:<skill-name> - skill.description/version read best-effort from <cwd>/.claude/skills/<name>/SKILL.md frontmatter (cwd from SystemMessage.data.cwd) - fallback to UserMessage.tool_use_result.commandName when start info incomplete - metadata read failures never propagate to the SDK call Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01bba83 commit 7f78738

4 files changed

Lines changed: 487 additions & 6 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Added
11+
12+
- Capture `gen_ai.skill.name`, `gen_ai.skill.id`, `gen_ai.skill.description`
13+
and `gen_ai.skill.version` on the `execute_tool` span of the built-in
14+
`Skill` tool. Skill metadata is read best-effort from the project-level
15+
`SKILL.md` frontmatter (located via `SystemMessage.data.cwd`); `skill.id`
16+
is reported as `claude:project:<skill-name>`. Metadata read failures never
17+
affect the SDK call.
18+
1019
## Version 0.6.0 (2026-06-03)
1120

1221
There are no changelog entries for this release.

instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
"""Patch functions for Claude Agent SDK instrumentation."""
1616

1717
import logging
18+
import os
1819
import time
1920
from typing import Any, Dict, List, Optional
2021

22+
import yaml
23+
2124
from opentelemetry import context as otel_context
2225
from opentelemetry.instrumentation.claude_agent_sdk.utils import (
2326
extract_usage_from_result_message,
@@ -86,6 +89,115 @@ def _clear_client_managed_runs() -> None:
8689
_client_managed_runs.clear()
8790

8891

92+
# The name of the Claude Agent SDK built-in tool that loads a Skill.
93+
_SKILL_TOOL_NAME = "Skill"
94+
95+
# skill id prefix for project-scoped Claude Agent SDK skills.
96+
_SKILL_ID_PREFIX = "claude:project:"
97+
98+
99+
def _read_skill_metadata(skill_md_path: str) -> Dict[str, str]:
100+
"""Best-effort read of a Skill's SKILL.md frontmatter.
101+
102+
Returns a dict with any of ``name``/``description``/``version`` keys that
103+
were present in the YAML frontmatter. On any error (missing file, parse
104+
failure, ...) returns an empty dict so telemetry never breaks the SDK call.
105+
"""
106+
try:
107+
with open(skill_md_path, "r", encoding="utf-8") as f:
108+
content = f.read()
109+
except Exception:
110+
# Missing or unreadable SKILL.md is expected for non-project skills.
111+
return {}
112+
113+
return _parse_skill_frontmatter(content)
114+
115+
116+
def _parse_skill_frontmatter(content: str) -> Dict[str, str]:
117+
"""Parse the YAML frontmatter (``---`` delimited) of a SKILL.md body."""
118+
try:
119+
stripped = content.lstrip()
120+
if not stripped.startswith("---"):
121+
return {}
122+
# Split off the leading ``---``; the next ``---`` closes the block.
123+
after_open = stripped[3:]
124+
end_index = after_open.find("\n---")
125+
if end_index == -1:
126+
# Frontmatter never closed; treat the remainder as the block.
127+
frontmatter_text = after_open
128+
else:
129+
frontmatter_text = after_open[:end_index]
130+
131+
parsed = yaml.safe_load(frontmatter_text)
132+
if not isinstance(parsed, dict):
133+
return {}
134+
except Exception:
135+
return {}
136+
137+
metadata: Dict[str, str] = {}
138+
for key in ("name", "description", "version"):
139+
value = parsed.get(key)
140+
if value is not None:
141+
metadata[key] = str(value)
142+
return metadata
143+
144+
145+
def _apply_skill_metadata(
146+
tool_invocation: ExecuteToolInvocation,
147+
skill_name: str,
148+
cwd: Optional[str],
149+
) -> None:
150+
"""Attach ``gen_ai.skill.*`` attributes to a Skill load tool span.
151+
152+
Reads the project-level ``SKILL.md`` frontmatter best-effort and fills in
153+
``skill_name``/``skill_id``/``skill_description``/``skill_version`` on the
154+
invocation. Any failure is swallowed so the SDK call is never affected.
155+
"""
156+
if not skill_name:
157+
return
158+
159+
metadata: Dict[str, str] = {}
160+
if cwd:
161+
skill_md_path = os.path.join(
162+
cwd, ".claude", "skills", skill_name, "SKILL.md"
163+
)
164+
metadata = _read_skill_metadata(skill_md_path)
165+
166+
# gen_ai.skill.name: prefer frontmatter, fall back to the requested name.
167+
name = metadata.get("name") or skill_name
168+
tool_invocation.skill_name = name
169+
tool_invocation.skill_id = f"{_SKILL_ID_PREFIX}{name}"
170+
171+
description = metadata.get("description")
172+
if description:
173+
tool_invocation.skill_description = description
174+
version = metadata.get("version")
175+
if version:
176+
tool_invocation.skill_version = version
177+
178+
179+
def _apply_skill_fallback(
180+
tool_invocation: ExecuteToolInvocation,
181+
tool_use_result: Any,
182+
) -> None:
183+
"""Best-effort fallback to recover skill_name before closing a Skill span.
184+
185+
If ``skill_name`` was not captured at span start (e.g. cwd was unavailable
186+
so SKILL.md could not be read), try ``UserMessage.tool_use_result.commandName``
187+
per the SDK's Skill tool result format.
188+
"""
189+
if tool_invocation.skill_name:
190+
return
191+
if not isinstance(tool_use_result, dict):
192+
return
193+
command_name = tool_use_result.get("commandName")
194+
if command_name:
195+
tool_invocation.skill_name = str(command_name)
196+
tool_invocation.skill_id = (
197+
f"{_SKILL_ID_PREFIX}{command_name}"
198+
)
199+
200+
89201
def _extract_message_parts(msg: Any) -> List[Any]:
90202
"""Extract parts (text + tool calls) from an AssistantMessage."""
91203
parts = []
@@ -113,12 +225,17 @@ def _create_tool_spans_from_message(
113225
agent_invocation: InvokeAgentInvocation,
114226
active_task_stack: List[Any],
115227
exclude_tool_names: Optional[List[str]] = None,
228+
cwd: Optional[str] = None,
116229
) -> None:
117230
"""Create tool execution spans from ToolUseBlocks in an AssistantMessage.
118231
119232
Tool spans are children of the active SubAgent span (if any), otherwise agent span.
120233
When a Task tool is created, it's pushed onto active_task_stack along with a SubAgent span.
121234
235+
For the built-in ``Skill`` tool, ``gen_ai.skill.*`` attributes are read
236+
best-effort from the project-level ``SKILL.md`` frontmatter (located via
237+
``cwd``) and attached to the tool span.
238+
122239
The stack structure is: [{"task": ExecuteToolInvocation, "subagent": InvokeAgentInvocation}, ...]
123240
"""
124241
if not hasattr(msg, "content"):
@@ -163,6 +280,26 @@ def _create_tool_spans_from_message(
163280
tool_call_arguments=tool_input,
164281
tool_description=tool_name,
165282
)
283+
284+
# Skill load: attach gen_ai.skill.* attributes best-effort
285+
# from the project SKILL.md frontmatter. Failures here must
286+
# never propagate to break the SDK call.
287+
if tool_name == _SKILL_TOOL_NAME:
288+
try:
289+
skill_name = ""
290+
if isinstance(tool_input, dict):
291+
skill_name = str(
292+
tool_input.get("skill") or ""
293+
)
294+
_apply_skill_metadata(
295+
tool_invocation, skill_name, cwd
296+
)
297+
except Exception as e:
298+
logger.warning(
299+
f"Failed to read Skill metadata for "
300+
f"'{tool_input}': {e}"
301+
)
302+
166303
handler.start_execute_tool(tool_invocation)
167304
_client_managed_runs[tool_use_id] = tool_invocation
168305

@@ -271,6 +408,7 @@ def _process_assistant_message(
271408
handler: ExtendedTelemetryHandler,
272409
collected_messages: List[Dict[str, Any]],
273410
active_task_stack: List[Any],
411+
cwd: Optional[str] = None,
274412
) -> None:
275413
"""Process AssistantMessage: create LLM turn, extract parts, create tool spans."""
276414
parts = _extract_message_parts(msg)
@@ -353,7 +491,7 @@ def _process_assistant_message(
353491
turn_tracker.close_llm_turn()
354492

355493
_create_tool_spans_from_message(
356-
msg, handler, agent_invocation, active_task_stack
494+
msg, handler, agent_invocation, active_task_stack, cwd=cwd
357495
)
358496

359497

@@ -474,6 +612,18 @@ def _process_user_message(
474612
Error(message=error_msg, type=RuntimeError),
475613
)
476614
else:
615+
# Skill load: best-effort fallback to fill skill_name
616+
# from the tool result if it wasn't captured at start.
617+
if tool_invocation.tool_name == _SKILL_TOOL_NAME:
618+
try:
619+
_apply_skill_fallback(
620+
tool_invocation, tool_use_result
621+
)
622+
except Exception as e:
623+
logger.warning(
624+
f"Failed to apply Skill metadata "
625+
f"fallback: {e}"
626+
)
477627
handler.stop_execute_tool(tool_invocation)
478628

479629
if tool_use_id:
@@ -522,18 +672,23 @@ def _process_user_message(
522672
def _process_system_message(
523673
msg: Any,
524674
agent_invocation: InvokeAgentInvocation,
525-
) -> None:
526-
"""Process SystemMessage: extract session_id early in the stream.
675+
) -> Optional[str]:
676+
"""Process SystemMessage: extract session_id and cwd early in the stream.
527677
528678
SystemMessage appears at the beginning of the message stream and contains
529-
the session_id in its data field. We extract it here so that it's available
530-
for all subsequent LLM spans.
679+
the session_id and cwd in its data field. We extract them here so they are
680+
available for all subsequent spans (cwd is needed to locate project-level
681+
SKILL.md files for Skill tool telemetry).
682+
683+
Returns the cwd if present, otherwise ``None``.
531684
"""
532685
if hasattr(msg, "subtype") and msg.subtype == "init":
533686
if hasattr(msg, "data") and isinstance(msg.data, dict):
534687
session_id = msg.data.get("session_id")
535688
if session_id:
536689
agent_invocation.conversation_id = session_id
690+
return msg.data.get("cwd")
691+
return None
537692

538693

539694
def _process_result_message(
@@ -590,12 +745,16 @@ async def _process_agent_invocation_stream(
590745
# When its ToolResultBlock is received, it's popped
591746
active_task_stack: List[Any] = []
592747

748+
# cwd captured from SystemMessage.data.cwd, used to locate project-level
749+
# SKILL.md files for Skill tool telemetry.
750+
session_cwd: Optional[str] = None
751+
593752
try:
594753
async for msg in wrapped_stream:
595754
msg_type = type(msg).__name__
596755

597756
if msg_type == "SystemMessage":
598-
_process_system_message(msg, agent_invocation)
757+
session_cwd = _process_system_message(msg, agent_invocation)
599758
elif msg_type == "AssistantMessage":
600759
_process_assistant_message(
601760
msg,
@@ -606,6 +765,7 @@ async def _process_agent_invocation_stream(
606765
handler,
607766
collected_messages,
608767
active_task_stack,
768+
cwd=session_cwd,
609769
)
610770
elif msg_type == "UserMessage":
611771
_process_user_message(
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
description: 'Skill load: project-level probe-skill loaded via Skill tool'
2+
prompt: Use the probe-skill Skill tool first. Then answer exactly PROBE_SKILL_MARKER and nothing else.
3+
messages:
4+
- type: SystemMessage
5+
subtype: init
6+
data:
7+
type: system
8+
subtype: init
9+
cwd: __SKILL_CWD__
10+
session_id: skill-session-0001
11+
tools:
12+
- Skill
13+
- Bash
14+
- Read
15+
skills:
16+
- probe-skill
17+
mcp_servers: []
18+
model: qwen-plus
19+
permissionMode: bypassPermissions
20+
apiKeySource: ANTHROPIC_API_KEY
21+
claude_code_version: 2.1.1
22+
output_style: default
23+
agents: []
24+
slash_commands: []
25+
plugins: []
26+
uuid: skill-init-uuid
27+
- type: AssistantMessage
28+
model: qwen-plus
29+
content:
30+
- type: ToolUseBlock
31+
id: call_skill_load_probe
32+
name: Skill
33+
input:
34+
skill: probe-skill
35+
parent_tool_use_id: null
36+
error: null
37+
- type: UserMessage
38+
content:
39+
- type: ToolResultBlock
40+
tool_use_id: call_skill_load_probe
41+
content: 'Launching skill: probe-skill'
42+
is_error: false
43+
uuid: skill-result-uuid
44+
parent_tool_use_id: null
45+
tool_use_result:
46+
success: true
47+
commandName: probe-skill
48+
- type: AssistantMessage
49+
model: qwen-plus
50+
content:
51+
- type: TextBlock
52+
text: PROBE_SKILL_MARKER
53+
parent_tool_use_id: null
54+
error: null
55+
- type: ResultMessage
56+
subtype: success
57+
duration_ms: 3210
58+
duration_api_ms: 9000
59+
is_error: false
60+
num_turns: 2
61+
session_id: skill-session-0001
62+
total_cost_usd: 0.012
63+
usage:
64+
input_tokens: 1024
65+
cache_creation_input_tokens: 0
66+
cache_read_input_tokens: 0
67+
output_tokens: 32
68+
server_tool_use:
69+
web_search_requests: 0
70+
web_fetch_requests: 0
71+
service_tier: standard
72+
cache_creation:
73+
ephemeral_1h_input_tokens: 0
74+
ephemeral_5m_input_tokens: 0
75+
result: PROBE_SKILL_MARKER
76+
structured_output: null

0 commit comments

Comments
 (0)