Skip to content

Commit 7d4bc85

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 7ffb142 commit 7d4bc85

6 files changed

Lines changed: 687 additions & 39 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
### Fixed
1120

1221
- Capture Claude Agent SDK session IDs on agent, LLM, and tool spans, and

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

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,14 @@ def _instrument(self, **kwargs: Any) -> None:
102102
wrap_function_wrapper(
103103
module="claude_agent_sdk",
104104
name="ClaudeSDKClient.__init__",
105-
wrapper=lambda wrapped,
106-
instance,
107-
args,
108-
kwargs: wrap_claude_client_init(
109-
wrapped,
110-
instance,
111-
args,
112-
kwargs,
113-
handler=ClaudeAgentSDKInstrumentor._handler,
105+
wrapper=lambda wrapped, instance, args, kwargs: (
106+
wrap_claude_client_init(
107+
wrapped,
108+
instance,
109+
args,
110+
kwargs,
111+
handler=ClaudeAgentSDKInstrumentor._handler,
112+
)
114113
),
115114
)
116115
except Exception as e:
@@ -122,15 +121,14 @@ def _instrument(self, **kwargs: Any) -> None:
122121
wrap_function_wrapper(
123122
module="claude_agent_sdk",
124123
name="ClaudeSDKClient.query",
125-
wrapper=lambda wrapped,
126-
instance,
127-
args,
128-
kwargs: wrap_claude_client_query(
129-
wrapped,
130-
instance,
131-
args,
132-
kwargs,
133-
handler=ClaudeAgentSDKInstrumentor._handler,
124+
wrapper=lambda wrapped, instance, args, kwargs: (
125+
wrap_claude_client_query(
126+
wrapped,
127+
instance,
128+
args,
129+
kwargs,
130+
handler=ClaudeAgentSDKInstrumentor._handler,
131+
)
134132
),
135133
)
136134
except Exception as e:
@@ -140,15 +138,14 @@ def _instrument(self, **kwargs: Any) -> None:
140138
wrap_function_wrapper(
141139
module="claude_agent_sdk",
142140
name="ClaudeSDKClient.receive_response",
143-
wrapper=lambda wrapped,
144-
instance,
145-
args,
146-
kwargs: wrap_claude_client_receive_response(
147-
wrapped,
148-
instance,
149-
args,
150-
kwargs,
151-
handler=ClaudeAgentSDKInstrumentor._handler,
141+
wrapper=lambda wrapped, instance, args, kwargs: (
142+
wrap_claude_client_receive_response(
143+
wrapped,
144+
instance,
145+
args,
146+
kwargs,
147+
handler=ClaudeAgentSDKInstrumentor._handler,
148+
)
152149
),
153150
)
154151
except Exception as e:

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

Lines changed: 199 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
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

@@ -133,6 +134,136 @@ def _clear_client_managed_runs(
133134
client_managed_runs.clear()
134135

135136

137+
# The name of the Claude Agent SDK built-in tool that loads a Skill.
138+
_SKILL_TOOL_NAME = "Skill"
139+
140+
# skill id prefix for project-scoped Claude Agent SDK skills.
141+
_SKILL_ID_PREFIX = "claude:project:"
142+
143+
144+
def _read_skill_metadata(skill_md_path: str) -> Dict[str, str]:
145+
"""Best-effort read of a Skill's SKILL.md frontmatter.
146+
147+
Returns a dict with any of ``name``/``description``/``version`` keys that
148+
were present in the YAML frontmatter. On any error (missing file, parse
149+
failure, ...) returns an empty dict so telemetry never breaks the SDK call.
150+
"""
151+
try:
152+
with open(skill_md_path, "r", encoding="utf-8") as f:
153+
content = f.read()
154+
except Exception:
155+
# Missing or unreadable SKILL.md is expected for non-project skills.
156+
return {}
157+
158+
return _parse_skill_frontmatter(content)
159+
160+
161+
def _parse_skill_frontmatter(content: str) -> Dict[str, str]:
162+
"""Parse selected scalar fields from SKILL.md frontmatter.
163+
164+
This intentionally avoids a runtime PyYAML dependency. Claude skill
165+
frontmatter only needs simple top-level scalar fields for telemetry.
166+
"""
167+
try:
168+
stripped = content.lstrip()
169+
if not stripped.startswith("---"):
170+
return {}
171+
# Split off the leading ``---``; the next ``---`` closes the block.
172+
after_open = stripped[3:]
173+
end_index = after_open.find("\n---")
174+
if end_index == -1:
175+
# Frontmatter never closed; treat the remainder as the block.
176+
frontmatter_text = after_open
177+
else:
178+
frontmatter_text = after_open[:end_index]
179+
except Exception:
180+
return {}
181+
182+
metadata: Dict[str, str] = {}
183+
wanted_keys = {"name", "description", "version"}
184+
for raw_line in frontmatter_text.splitlines():
185+
line = raw_line.strip()
186+
if not line or line.startswith("#") or ":" not in line:
187+
continue
188+
189+
key, value = line.split(":", 1)
190+
key = key.strip()
191+
if key not in wanted_keys:
192+
continue
193+
194+
value = value.strip()
195+
if (
196+
len(value) >= 2
197+
and value[0] == value[-1]
198+
and value[0]
199+
in {
200+
'"',
201+
"'",
202+
}
203+
):
204+
value = value[1:-1]
205+
if value:
206+
metadata[key] = value
207+
return metadata
208+
209+
210+
def _apply_skill_metadata(
211+
tool_invocation: ExecuteToolInvocation,
212+
skill_name: str,
213+
cwd: Optional[str],
214+
) -> None:
215+
"""Attach ``gen_ai.skill.*`` attributes to a Skill load tool span.
216+
217+
Reads the project-level ``SKILL.md`` frontmatter best-effort and fills in
218+
``skill_name``/``skill_id``/``skill_description``/``skill_version`` on the
219+
invocation. Any failure is swallowed so the SDK call is never affected.
220+
"""
221+
if not skill_name:
222+
return
223+
224+
metadata: Dict[str, str] = {}
225+
if cwd:
226+
skill_md_path = os.path.join(
227+
cwd, ".claude", "skills", skill_name, "SKILL.md"
228+
)
229+
metadata = _read_skill_metadata(skill_md_path)
230+
231+
# gen_ai.skill.name: prefer the requested tool input; frontmatter is
232+
# supplemental metadata for description/version.
233+
name = skill_name or metadata.get("name")
234+
if not name:
235+
return
236+
tool_invocation.skill_name = name
237+
tool_invocation.skill_id = f"{_SKILL_ID_PREFIX}{name}"
238+
239+
description = metadata.get("description")
240+
if description:
241+
tool_invocation.skill_description = description
242+
version = metadata.get("version")
243+
if version:
244+
tool_invocation.skill_version = version
245+
246+
247+
def _apply_skill_fallback(
248+
tool_invocation: ExecuteToolInvocation,
249+
tool_use_result: Any,
250+
) -> None:
251+
"""Best-effort fallback to recover skill_name before closing a Skill span.
252+
253+
If ``skill_name`` was not captured at span start (e.g. cwd was unavailable
254+
so SKILL.md could not be read), try ``UserMessage.tool_use_result.commandName``
255+
per the SDK's Skill tool result format.
256+
"""
257+
if tool_invocation.skill_name:
258+
return
259+
if not isinstance(tool_use_result, dict):
260+
return
261+
command_name = tool_use_result.get("commandName")
262+
if command_name:
263+
tool_invocation.skill_name = str(command_name)
264+
tool_invocation.skill_id = f"{_SKILL_ID_PREFIX}{command_name}"
265+
266+
136267
def _extract_message_parts(msg: Any) -> List[Any]:
137268
"""Extract parts (text + tool calls) from an AssistantMessage."""
138269
parts = []
@@ -161,12 +292,17 @@ def _create_tool_spans_from_message(
161292
active_task_stack: List[Any],
162293
client_managed_runs: Dict[str, ExecuteToolInvocation],
163294
exclude_tool_names: Optional[List[str]] = None,
295+
cwd: Optional[str] = None,
164296
) -> None:
165297
"""Create tool execution spans from ToolUseBlocks in an AssistantMessage.
166298
167299
Tool spans are children of the active SubAgent span (if any), otherwise agent span.
168300
When a Task tool is created, it's pushed onto active_task_stack along with a SubAgent span.
169301
302+
For the built-in ``Skill`` tool, ``gen_ai.skill.*`` attributes are read
303+
best-effort from the project-level ``SKILL.md`` frontmatter (located via
304+
``cwd``) and attached to the tool span.
305+
170306
The stack structure is: [{"task": ExecuteToolInvocation, "subagent": InvokeAgentInvocation}, ...]
171307
"""
172308
if not hasattr(msg, "content"):
@@ -214,6 +350,22 @@ def _create_tool_spans_from_message(
214350
_apply_session_identity(
215351
tool_invocation, agent_invocation.conversation_id
216352
)
353+
354+
# Skill load: attach gen_ai.skill.* attributes best-effort
355+
# from the project SKILL.md frontmatter. Failures here must
356+
# never propagate to break the SDK call.
357+
if tool_name == _SKILL_TOOL_NAME:
358+
try:
359+
skill_name = ""
360+
if isinstance(tool_input, dict):
361+
skill_name = str(tool_input.get("skill") or "")
362+
_apply_skill_metadata(tool_invocation, skill_name, cwd)
363+
except Exception as e:
364+
logger.warning(
365+
f"Failed to read Skill metadata for "
366+
f"'{tool_input}': {e}"
367+
)
368+
217369
handler.start_execute_tool(tool_invocation)
218370
client_managed_runs[tool_use_id] = tool_invocation
219371

@@ -327,6 +479,7 @@ def _process_assistant_message(
327479
collected_messages: List[Dict[str, Any]],
328480
active_task_stack: List[Any],
329481
client_managed_runs: Dict[str, ExecuteToolInvocation],
482+
cwd: Optional[str] = None,
330483
) -> None:
331484
"""Process AssistantMessage: create LLM turn, extract parts, create tool spans."""
332485
parts = _extract_message_parts(msg)
@@ -414,6 +567,7 @@ def _process_assistant_message(
414567
agent_invocation,
415568
active_task_stack,
416569
client_managed_runs,
570+
cwd=cwd,
417571
)
418572

419573

@@ -535,6 +689,18 @@ def _process_user_message(
535689
Error(message=error_msg, type=RuntimeError),
536690
)
537691
else:
692+
# Skill load: best-effort fallback to fill skill_name
693+
# from the tool result if it wasn't captured at start.
694+
if tool_invocation.tool_name == _SKILL_TOOL_NAME:
695+
try:
696+
_apply_skill_fallback(
697+
tool_invocation, tool_use_result
698+
)
699+
except Exception as e:
700+
logger.warning(
701+
f"Failed to apply Skill metadata "
702+
f"fallback: {e}"
703+
)
538704
handler.stop_execute_tool(tool_invocation)
539705

540706
if tool_use_id:
@@ -583,18 +749,25 @@ def _process_user_message(
583749
def _process_system_message(
584750
msg: Any,
585751
agent_invocation: InvokeAgentInvocation,
586-
) -> None:
587-
"""Process SystemMessage: extract session_id early in the stream.
752+
) -> Optional[str]:
753+
"""Process SystemMessage: extract session_id and cwd early in the stream.
588754
589755
SystemMessage appears at the beginning of the message stream and contains
590-
the session_id in its data field. We extract it here so that it's available
591-
for all subsequent LLM spans.
756+
the session_id and cwd in its data field. We extract them here so they are
757+
available for all subsequent spans (cwd is needed to locate project-level
758+
SKILL.md files for Skill tool telemetry).
759+
760+
Returns the cwd if present, otherwise ``None``.
592761
"""
593762
if hasattr(msg, "subtype") and msg.subtype == "init":
594763
if hasattr(msg, "data") and isinstance(msg.data, dict):
595764
session_id = msg.data.get("session_id")
596765
if session_id:
597766
_set_session_id(agent_invocation, session_id)
767+
cwd = msg.data.get("cwd")
768+
if cwd:
769+
return str(cwd)
770+
return None
598771

599772

600773
def _process_stream_event_message(
@@ -670,12 +843,19 @@ async def _process_agent_invocation_stream(
670843
active_task_stack: List[Any] = []
671844
client_managed_runs: Dict[str, ExecuteToolInvocation] = {}
672845

846+
# cwd captured from SystemMessage.data.cwd, used to locate project-level
847+
# SKILL.md files for Skill tool telemetry.
848+
session_cwd: Optional[str] = None
849+
agent_closed = False
850+
673851
try:
674852
async for msg in wrapped_stream:
675853
msg_type = type(msg).__name__
676854

677855
if msg_type == "SystemMessage":
678-
_process_system_message(msg, agent_invocation)
856+
cwd = _process_system_message(msg, agent_invocation)
857+
if cwd:
858+
session_cwd = cwd
679859
elif msg_type == "StreamEvent":
680860
_process_stream_event_message(msg, agent_invocation)
681861
elif msg_type == "AssistantMessage":
@@ -689,6 +869,7 @@ async def _process_agent_invocation_stream(
689869
collected_messages,
690870
active_task_stack,
691871
client_managed_runs,
872+
cwd=session_cwd,
692873
)
693874
elif msg_type == "UserMessage":
694875
_process_user_message(
@@ -705,15 +886,21 @@ async def _process_agent_invocation_stream(
705886
yield msg
706887

707888
handler.stop_invoke_agent(agent_invocation)
889+
agent_closed = True
708890

709-
except Exception as e:
891+
except BaseException as e:
710892
error_msg = str(e)
711-
if agent_invocation.span:
712-
agent_invocation.span.set_attribute("error.type", type(e).__name__)
713-
agent_invocation.span.set_attribute("error.message", error_msg)
714-
handler.fail_invoke_agent(
715-
agent_invocation, error=Error(message=error_msg, type=type(e))
716-
)
893+
if not agent_closed:
894+
if agent_invocation.span:
895+
agent_invocation.span.set_attribute(
896+
"error.type", type(e).__name__
897+
)
898+
agent_invocation.span.set_attribute("error.message", error_msg)
899+
handler.fail_invoke_agent(
900+
agent_invocation,
901+
error=Error(message=error_msg, type=type(e)),
902+
)
903+
agent_closed = True
717904

718905
raise
719906
finally:

0 commit comments

Comments
 (0)