moda-claude-agent-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- moda_claude_agent_sdk-0.1.0/.gitignore +188 -0
- moda_claude_agent_sdk-0.1.0/.python-version +1 -0
- moda_claude_agent_sdk-0.1.0/PKG-INFO +19 -0
- moda_claude_agent_sdk-0.1.0/README.md +3 -0
- moda_claude_agent_sdk-0.1.0/opentelemetry/instrumentation/claude_agent_sdk/__init__.py +172 -0
- moda_claude_agent_sdk-0.1.0/opentelemetry/instrumentation/claude_agent_sdk/streaming.py +206 -0
- moda_claude_agent_sdk-0.1.0/opentelemetry/instrumentation/claude_agent_sdk/version.py +1 -0
- moda_claude_agent_sdk-0.1.0/project.json +77 -0
- moda_claude_agent_sdk-0.1.0/pyproject.toml +74 -0
- moda_claude_agent_sdk-0.1.0/tests/conftest.py +18 -0
- moda_claude_agent_sdk-0.1.0/tests/test_agent_instrumentor.py +598 -0
- moda_claude_agent_sdk-0.1.0/tests/test_runner_e2e.py +419 -0
- moda_claude_agent_sdk-0.1.0/uv.lock +1158 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# compiled output
|
|
4
|
+
dist
|
|
5
|
+
tmp
|
|
6
|
+
/out-tsc
|
|
7
|
+
|
|
8
|
+
# dependencies
|
|
9
|
+
node_modules
|
|
10
|
+
|
|
11
|
+
# IDEs and editors
|
|
12
|
+
/.idea
|
|
13
|
+
.project
|
|
14
|
+
.classpath
|
|
15
|
+
.c9/
|
|
16
|
+
*.launch
|
|
17
|
+
.settings/
|
|
18
|
+
*.sublime-workspace
|
|
19
|
+
|
|
20
|
+
# IDE - VSCode
|
|
21
|
+
.vscode/*
|
|
22
|
+
!.vscode/settings.json
|
|
23
|
+
!.vscode/tasks.json
|
|
24
|
+
!.vscode/launch.json
|
|
25
|
+
!.vscode/extensions.json
|
|
26
|
+
|
|
27
|
+
# misc
|
|
28
|
+
/.sass-cache
|
|
29
|
+
/connect.lock
|
|
30
|
+
/coverage
|
|
31
|
+
/libpeerconnection.log
|
|
32
|
+
npm-debug.log
|
|
33
|
+
yarn-error.log
|
|
34
|
+
testem.log
|
|
35
|
+
/typings
|
|
36
|
+
|
|
37
|
+
# System Files
|
|
38
|
+
.DS_Store
|
|
39
|
+
Thumbs.db
|
|
40
|
+
|
|
41
|
+
.cache/
|
|
42
|
+
reports/
|
|
43
|
+
.vscode/
|
|
44
|
+
|
|
45
|
+
# Byte-compiled / optimized / DLL files
|
|
46
|
+
__pycache__/
|
|
47
|
+
*.py[cod]
|
|
48
|
+
*$py.class
|
|
49
|
+
|
|
50
|
+
# C extensions
|
|
51
|
+
*.so
|
|
52
|
+
|
|
53
|
+
# Distribution / packaging
|
|
54
|
+
.Python
|
|
55
|
+
build/
|
|
56
|
+
develop-eggs/
|
|
57
|
+
dist/
|
|
58
|
+
downloads/
|
|
59
|
+
eggs/
|
|
60
|
+
.eggs/
|
|
61
|
+
lib/
|
|
62
|
+
lib64/
|
|
63
|
+
parts/
|
|
64
|
+
sdist/
|
|
65
|
+
var/
|
|
66
|
+
wheels/
|
|
67
|
+
share/python-wheels/
|
|
68
|
+
*.egg-info/
|
|
69
|
+
.installed.cfg
|
|
70
|
+
*.egg
|
|
71
|
+
MANIFEST
|
|
72
|
+
|
|
73
|
+
# PyInstaller
|
|
74
|
+
# Usually these files are written by a python script from a template
|
|
75
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
76
|
+
*.manifest
|
|
77
|
+
*.spec
|
|
78
|
+
|
|
79
|
+
# Installer logs
|
|
80
|
+
pip-log.txt
|
|
81
|
+
pip-delete-this-directory.txt
|
|
82
|
+
|
|
83
|
+
# Unit test / coverage reports
|
|
84
|
+
htmlcov/
|
|
85
|
+
.tox/
|
|
86
|
+
.nox/
|
|
87
|
+
.coverage
|
|
88
|
+
.coverage.*
|
|
89
|
+
.cache
|
|
90
|
+
nosetests.xml
|
|
91
|
+
coverage.xml
|
|
92
|
+
*.cover
|
|
93
|
+
*.py,cover
|
|
94
|
+
.hypothesis/
|
|
95
|
+
.pytest_cache/
|
|
96
|
+
cover/
|
|
97
|
+
|
|
98
|
+
# Translations
|
|
99
|
+
*.mo
|
|
100
|
+
*.pot
|
|
101
|
+
|
|
102
|
+
# Django stuff:
|
|
103
|
+
*.log
|
|
104
|
+
local_settings.py
|
|
105
|
+
db.sqlite3
|
|
106
|
+
db.sqlite3-journal
|
|
107
|
+
|
|
108
|
+
# Flask stuff:
|
|
109
|
+
instance/
|
|
110
|
+
.webassets-cache
|
|
111
|
+
|
|
112
|
+
# Scrapy stuff:
|
|
113
|
+
.scrapy
|
|
114
|
+
|
|
115
|
+
# Sphinx documentation
|
|
116
|
+
docs/_build/
|
|
117
|
+
|
|
118
|
+
# PyBuilder
|
|
119
|
+
.pybuilder/
|
|
120
|
+
target/
|
|
121
|
+
|
|
122
|
+
# Jupyter Notebook
|
|
123
|
+
.ipynb_checkpoints
|
|
124
|
+
|
|
125
|
+
# IPython
|
|
126
|
+
profile_default/
|
|
127
|
+
ipython_config.py
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# SageMath parsed files
|
|
137
|
+
*.sage.py
|
|
138
|
+
|
|
139
|
+
# Environments
|
|
140
|
+
.env
|
|
141
|
+
.venv
|
|
142
|
+
env/
|
|
143
|
+
venv/
|
|
144
|
+
ENV/
|
|
145
|
+
env.bak/
|
|
146
|
+
venv.bak/
|
|
147
|
+
|
|
148
|
+
# Spyder project settings
|
|
149
|
+
.spyderproject
|
|
150
|
+
.spyproject
|
|
151
|
+
|
|
152
|
+
# Rope project settings
|
|
153
|
+
.ropeproject
|
|
154
|
+
|
|
155
|
+
# mkdocs documentation
|
|
156
|
+
/site
|
|
157
|
+
|
|
158
|
+
# mypy
|
|
159
|
+
.mypy_cache/
|
|
160
|
+
.dmypy.json
|
|
161
|
+
dmypy.json
|
|
162
|
+
|
|
163
|
+
# Pyre type checker
|
|
164
|
+
.pyre/
|
|
165
|
+
|
|
166
|
+
# pytype static type analyzer
|
|
167
|
+
.pytype/
|
|
168
|
+
|
|
169
|
+
# Cython debug symbols
|
|
170
|
+
cython_debug/
|
|
171
|
+
|
|
172
|
+
# PyCharm
|
|
173
|
+
.idea/
|
|
174
|
+
|
|
175
|
+
# NX
|
|
176
|
+
.nx
|
|
177
|
+
|
|
178
|
+
# Test artifcats
|
|
179
|
+
chroma.sqlite3
|
|
180
|
+
|
|
181
|
+
# Claude
|
|
182
|
+
.claude
|
|
183
|
+
|
|
184
|
+
# Development files
|
|
185
|
+
packages/sample-app/sample_app/development/
|
|
186
|
+
|
|
187
|
+
# VCR test cassettes
|
|
188
|
+
**/tests/cassettes/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.10
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moda-claude-agent-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Moda Claude Agent SDK instrumentation for LLM observability
|
|
5
|
+
Project-URL: Repository, https://github.com/ModaLabs/moda-python/tree/main/packages/moda-claude-agent-sdk
|
|
6
|
+
Author-email: Moda Labs <support@moda.so>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: <4,>=3.10
|
|
9
|
+
Requires-Dist: opentelemetry-api<2,>=1.38.0
|
|
10
|
+
Requires-Dist: opentelemetry-instrumentation>=0.59b0
|
|
11
|
+
Requires-Dist: opentelemetry-semantic-conventions-ai<0.5.0,>=0.4.13
|
|
12
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.59b0
|
|
13
|
+
Provides-Extra: instruments
|
|
14
|
+
Requires-Dist: claude-agent-sdk; extra == 'instruments'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# moda-claude-agent-sdk
|
|
18
|
+
|
|
19
|
+
OpenTelemetry instrumentation for the Claude Agent SDK.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""OpenTelemetry Claude Agent SDK instrumentation.
|
|
2
|
+
|
|
3
|
+
Instruments the Claude Agent SDK (claude-agent-sdk) to capture agent execution
|
|
4
|
+
spans. Since the Claude Agent SDK spawns Claude Code as a subprocess, standard
|
|
5
|
+
Anthropic API instrumentation doesn't capture any calls. This instrumentor wraps
|
|
6
|
+
the SDK's own methods to extract conversation data from the messages it yields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Collection
|
|
11
|
+
|
|
12
|
+
from opentelemetry.instrumentation.claude_agent_sdk.streaming import (
|
|
13
|
+
WrappedAgentStream,
|
|
14
|
+
_set_span_attribute,
|
|
15
|
+
)
|
|
16
|
+
from opentelemetry.instrumentation.claude_agent_sdk.version import __version__
|
|
17
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
|
18
|
+
from opentelemetry.instrumentation.utils import unwrap
|
|
19
|
+
from opentelemetry.trace import SpanKind, get_tracer
|
|
20
|
+
from wrapt import wrap_function_wrapper
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_instruments = ("claude-agent-sdk",)
|
|
25
|
+
|
|
26
|
+
WRAPPED_METHODS = [
|
|
27
|
+
{
|
|
28
|
+
"package": "claude_agent_sdk",
|
|
29
|
+
"object": "ClaudeSDKClient",
|
|
30
|
+
"method": "query",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"package": "claude_agent_sdk",
|
|
34
|
+
"object": "ClaudeSDKClient",
|
|
35
|
+
"method": "receive_response",
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClaudeAgentSDKInstrumentor(BaseInstrumentor):
|
|
41
|
+
"""OpenTelemetry instrumentor for the Claude Agent SDK."""
|
|
42
|
+
|
|
43
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
|
44
|
+
return _instruments
|
|
45
|
+
|
|
46
|
+
def _instrument(self, **kwargs):
|
|
47
|
+
tracer_provider = kwargs.get("tracer_provider")
|
|
48
|
+
tracer = get_tracer(__name__, __version__, tracer_provider)
|
|
49
|
+
|
|
50
|
+
# Wrap query() — async method that sends the prompt
|
|
51
|
+
try:
|
|
52
|
+
wrap_function_wrapper(
|
|
53
|
+
"claude_agent_sdk",
|
|
54
|
+
"ClaudeSDKClient.query",
|
|
55
|
+
_wrap_query(tracer),
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.debug(f"Failed to wrap ClaudeSDKClient.query: {e}")
|
|
59
|
+
|
|
60
|
+
# Wrap receive_response() — async generator method
|
|
61
|
+
try:
|
|
62
|
+
wrap_function_wrapper(
|
|
63
|
+
"claude_agent_sdk",
|
|
64
|
+
"ClaudeSDKClient.receive_response",
|
|
65
|
+
_wrap_receive_response(tracer),
|
|
66
|
+
)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.debug(f"Failed to wrap ClaudeSDKClient.receive_response: {e}")
|
|
69
|
+
|
|
70
|
+
def _uninstrument(self, **kwargs):
|
|
71
|
+
import claude_agent_sdk
|
|
72
|
+
|
|
73
|
+
for method_info in WRAPPED_METHODS:
|
|
74
|
+
try:
|
|
75
|
+
obj = getattr(claude_agent_sdk, method_info["object"])
|
|
76
|
+
unwrap(obj, method_info["method"])
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.debug(f"Failed to unwrap {method_info['object']}.{method_info['method']}: {e}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _wrap_query(tracer):
|
|
82
|
+
"""Wrap ClaudeSDKClient.query() to capture the prompt on the instance."""
|
|
83
|
+
|
|
84
|
+
async def wrapper(wrapped, instance, args, kwargs):
|
|
85
|
+
# Store the prompt on the instance so receive_response wrapper can access it
|
|
86
|
+
prompt = args[0] if args else kwargs.get("prompt", "")
|
|
87
|
+
instance._moda_last_prompt = prompt
|
|
88
|
+
return await wrapped(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _wrap_receive_response(tracer):
|
|
94
|
+
"""Wrap ClaudeSDKClient.receive_response() to create a span and wrap the stream.
|
|
95
|
+
|
|
96
|
+
receive_response() is an async generator function. Our wrapper must also be
|
|
97
|
+
an async generator — we yield each message through a WrappedAgentStream that
|
|
98
|
+
accumulates token usage and finalizes the span when the stream is exhausted.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
async def wrapper(wrapped, instance, args, kwargs):
|
|
102
|
+
# Get the model from instance options (defensive access)
|
|
103
|
+
model = _get_model(instance)
|
|
104
|
+
|
|
105
|
+
# Get conversation/user context from Moda context vars
|
|
106
|
+
conversation_id = None
|
|
107
|
+
user_id = None
|
|
108
|
+
try:
|
|
109
|
+
from traceloop.sdk.conversation import get_conversation_id, get_user_id
|
|
110
|
+
|
|
111
|
+
conversation_id = get_conversation_id()
|
|
112
|
+
user_id = get_user_id()
|
|
113
|
+
except ImportError:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Create span for this agent run
|
|
117
|
+
span = tracer.start_span(
|
|
118
|
+
"claude_agent.chat",
|
|
119
|
+
kind=SpanKind.CLIENT,
|
|
120
|
+
attributes={
|
|
121
|
+
"gen_ai.system": "Anthropic",
|
|
122
|
+
"llm.request_type": "chat",
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Set known attributes upfront
|
|
127
|
+
if model:
|
|
128
|
+
_set_span_attribute(span, "gen_ai.request.model", model)
|
|
129
|
+
if conversation_id:
|
|
130
|
+
_set_span_attribute(span, "moda.conversation_id", conversation_id)
|
|
131
|
+
if user_id:
|
|
132
|
+
_set_span_attribute(span, "moda.user_id", user_id)
|
|
133
|
+
|
|
134
|
+
# Capture the prompt if stored by query() wrapper
|
|
135
|
+
prompt = getattr(instance, "_moda_last_prompt", None)
|
|
136
|
+
if prompt:
|
|
137
|
+
_set_span_attribute(span, "gen_ai.prompt", str(prompt)[:1000])
|
|
138
|
+
|
|
139
|
+
# Call original async generator and wrap it
|
|
140
|
+
async_gen = wrapped(*args, **kwargs)
|
|
141
|
+
stream = WrappedAgentStream(async_gen, instance, span)
|
|
142
|
+
|
|
143
|
+
# Yield through — try/finally ensures span is finalized on early exit (break, cancel)
|
|
144
|
+
try:
|
|
145
|
+
async for msg in stream:
|
|
146
|
+
yield msg
|
|
147
|
+
finally:
|
|
148
|
+
stream._finalize()
|
|
149
|
+
|
|
150
|
+
return wrapper
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_model(instance) -> str:
|
|
154
|
+
"""Defensively extract the model name from a ClaudeSDKClient instance."""
|
|
155
|
+
# Try instance.options.model
|
|
156
|
+
options = getattr(instance, "options", None)
|
|
157
|
+
if options:
|
|
158
|
+
model = getattr(options, "model", None)
|
|
159
|
+
if model:
|
|
160
|
+
return str(model)
|
|
161
|
+
|
|
162
|
+
# Try instance.model
|
|
163
|
+
model = getattr(instance, "model", None)
|
|
164
|
+
if model:
|
|
165
|
+
return str(model)
|
|
166
|
+
|
|
167
|
+
# Try instance._model
|
|
168
|
+
model = getattr(instance, "_model", None)
|
|
169
|
+
if model:
|
|
170
|
+
return str(model)
|
|
171
|
+
|
|
172
|
+
return ""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Wrapped async generator for Claude Agent SDK receive_response() streams."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from opentelemetry.trace import Span
|
|
6
|
+
from opentelemetry.trace.status import Status, StatusCode
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _set_span_attribute(span: Span, name: str, value):
|
|
12
|
+
"""Set a span attribute only if value is not None."""
|
|
13
|
+
if value is not None and value != "":
|
|
14
|
+
try:
|
|
15
|
+
span.set_attribute(name, value)
|
|
16
|
+
except Exception:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get(obj, key, default=None):
|
|
21
|
+
"""Get a value from an object that may be a dict or an object with attributes."""
|
|
22
|
+
if isinstance(obj, dict):
|
|
23
|
+
return obj.get(key, default)
|
|
24
|
+
return getattr(obj, key, default)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WrappedAgentStream:
|
|
28
|
+
"""Async iterator proxy that wraps the Claude Agent SDK's receive_response() async generator.
|
|
29
|
+
|
|
30
|
+
Yields each message through while accumulating token usage, tool call counts,
|
|
31
|
+
and agent metadata. Finalizes the span when the stream is exhausted.
|
|
32
|
+
|
|
33
|
+
Token data comes from two possible sources:
|
|
34
|
+
1. ResultMessage.usage dict (primary — always present at end of run)
|
|
35
|
+
2. StreamEvent with message_start/message_delta (when include_partial_messages=True)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, async_gen, instance, span: Span):
|
|
39
|
+
self._async_gen = async_gen
|
|
40
|
+
self._instance = instance
|
|
41
|
+
self._span = span
|
|
42
|
+
self._finalized = False
|
|
43
|
+
|
|
44
|
+
# Accumulated data
|
|
45
|
+
self._input_tokens = 0
|
|
46
|
+
self._output_tokens = 0
|
|
47
|
+
self._tool_call_count = 0
|
|
48
|
+
self._num_turns = None
|
|
49
|
+
self._session_id = None
|
|
50
|
+
self._model = None
|
|
51
|
+
|
|
52
|
+
def __aiter__(self):
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
async def __anext__(self):
|
|
56
|
+
try:
|
|
57
|
+
msg = await self._async_gen.__anext__()
|
|
58
|
+
except StopAsyncIteration:
|
|
59
|
+
self._finalize()
|
|
60
|
+
raise
|
|
61
|
+
except Exception as e:
|
|
62
|
+
self._finalize(error=e)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
self._process_message(msg)
|
|
66
|
+
return msg
|
|
67
|
+
|
|
68
|
+
async def aclose(self):
|
|
69
|
+
"""Finalize span when consumer exits the loop early (break, cancel, etc.)."""
|
|
70
|
+
self._finalize()
|
|
71
|
+
if hasattr(self._async_gen, 'aclose'):
|
|
72
|
+
await self._async_gen.aclose()
|
|
73
|
+
|
|
74
|
+
def _process_message(self, msg):
|
|
75
|
+
"""Extract data from each message yielded by the agent stream."""
|
|
76
|
+
try:
|
|
77
|
+
msg_type = type(msg).__name__
|
|
78
|
+
|
|
79
|
+
# Type-name matching with duck-typing fallbacks for robustness
|
|
80
|
+
if msg_type == "ResultMessage" or (
|
|
81
|
+
hasattr(msg, "num_turns") and hasattr(msg, "usage")
|
|
82
|
+
):
|
|
83
|
+
self._handle_result_message(msg)
|
|
84
|
+
elif msg_type == "AssistantMessage" or (
|
|
85
|
+
hasattr(msg, "model") and hasattr(msg, "content")
|
|
86
|
+
):
|
|
87
|
+
self._handle_assistant_message(msg)
|
|
88
|
+
elif msg_type == "StreamEvent" or hasattr(msg, "event"):
|
|
89
|
+
self._handle_stream_event(msg)
|
|
90
|
+
# SystemMessage, UserMessage, etc. — ignored
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.debug(f"Error processing agent message: {e}")
|
|
93
|
+
|
|
94
|
+
def _handle_result_message(self, msg):
|
|
95
|
+
"""Extract token usage and metadata from the final ResultMessage.
|
|
96
|
+
|
|
97
|
+
ResultMessage.usage is a dict like:
|
|
98
|
+
{'input_tokens': 10, 'output_tokens': 91,
|
|
99
|
+
'cache_read_input_tokens': 1952, 'cache_creation_input_tokens': 0, ...}
|
|
100
|
+
"""
|
|
101
|
+
self._num_turns = getattr(msg, "num_turns", None)
|
|
102
|
+
self._session_id = getattr(msg, "session_id", None)
|
|
103
|
+
|
|
104
|
+
# Token usage from ResultMessage.usage dict (primary source)
|
|
105
|
+
usage = getattr(msg, "usage", None)
|
|
106
|
+
if usage and isinstance(usage, dict):
|
|
107
|
+
input_tokens = usage.get("input_tokens", 0) or 0
|
|
108
|
+
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
|
109
|
+
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
|
|
110
|
+
output_tokens = usage.get("output_tokens", 0) or 0
|
|
111
|
+
|
|
112
|
+
# Total input includes cache tokens
|
|
113
|
+
total_input = input_tokens + cache_read + cache_create
|
|
114
|
+
|
|
115
|
+
# Only override if we got real data (don't clobber StreamEvent accumulation)
|
|
116
|
+
if total_input > 0 or output_tokens > 0:
|
|
117
|
+
self._input_tokens = total_input
|
|
118
|
+
self._output_tokens = output_tokens
|
|
119
|
+
|
|
120
|
+
def _handle_assistant_message(self, msg):
|
|
121
|
+
"""Extract model name and count tool calls from assistant messages."""
|
|
122
|
+
# Model name
|
|
123
|
+
model = getattr(msg, "model", None)
|
|
124
|
+
if model:
|
|
125
|
+
self._model = str(model)
|
|
126
|
+
|
|
127
|
+
# Count tool use blocks
|
|
128
|
+
content = getattr(msg, "content", None)
|
|
129
|
+
if not content:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if isinstance(content, list):
|
|
133
|
+
for block in content:
|
|
134
|
+
block_type = type(block).__name__
|
|
135
|
+
if block_type == "ToolUseBlock":
|
|
136
|
+
self._tool_call_count += 1
|
|
137
|
+
elif hasattr(block, "type"):
|
|
138
|
+
attr_type = getattr(block, "type", None)
|
|
139
|
+
if attr_type == "tool_use":
|
|
140
|
+
self._tool_call_count += 1
|
|
141
|
+
|
|
142
|
+
def _handle_stream_event(self, msg):
|
|
143
|
+
"""Extract token usage from raw Anthropic streaming events.
|
|
144
|
+
|
|
145
|
+
Only present when include_partial_messages=True. The event field is a dict.
|
|
146
|
+
These are accumulated across turns; ResultMessage.usage takes precedence
|
|
147
|
+
if present (see _handle_result_message).
|
|
148
|
+
"""
|
|
149
|
+
event = getattr(msg, "event", None)
|
|
150
|
+
if event is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
event_type = _get(event, "type")
|
|
154
|
+
|
|
155
|
+
if event_type == "message_start":
|
|
156
|
+
message = _get(event, "message")
|
|
157
|
+
if message:
|
|
158
|
+
usage = _get(message, "usage")
|
|
159
|
+
if usage:
|
|
160
|
+
input_tokens = _get(usage, "input_tokens", 0)
|
|
161
|
+
if input_tokens:
|
|
162
|
+
self._input_tokens += input_tokens
|
|
163
|
+
model = _get(message, "model")
|
|
164
|
+
if model:
|
|
165
|
+
self._model = model
|
|
166
|
+
|
|
167
|
+
elif event_type == "message_delta":
|
|
168
|
+
usage = _get(event, "usage")
|
|
169
|
+
if usage:
|
|
170
|
+
output_tokens = _get(usage, "output_tokens", 0)
|
|
171
|
+
if output_tokens:
|
|
172
|
+
self._output_tokens += output_tokens
|
|
173
|
+
|
|
174
|
+
def _finalize(self, error=None):
|
|
175
|
+
"""Set accumulated attributes on the span and end it."""
|
|
176
|
+
if self._finalized:
|
|
177
|
+
return
|
|
178
|
+
self._finalized = True
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
if error:
|
|
182
|
+
self._span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
183
|
+
_set_span_attribute(self._span, "error.type", type(error).__name__)
|
|
184
|
+
else:
|
|
185
|
+
self._span.set_status(Status(StatusCode.OK))
|
|
186
|
+
|
|
187
|
+
# Model — only update response model; request model was set upfront
|
|
188
|
+
if self._model:
|
|
189
|
+
_set_span_attribute(self._span, "gen_ai.response.model", self._model)
|
|
190
|
+
|
|
191
|
+
# Token usage
|
|
192
|
+
_set_span_attribute(self._span, "gen_ai.usage.input_tokens", self._input_tokens)
|
|
193
|
+
_set_span_attribute(self._span, "gen_ai.usage.output_tokens", self._output_tokens)
|
|
194
|
+
_set_span_attribute(
|
|
195
|
+
self._span, "llm.usage.total_tokens", self._input_tokens + self._output_tokens
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Agent-specific attributes
|
|
199
|
+
_set_span_attribute(self._span, "claude_agent.num_turns", self._num_turns)
|
|
200
|
+
_set_span_attribute(self._span, "claude_agent.session_id", self._session_id)
|
|
201
|
+
_set_span_attribute(self._span, "claude_agent.tool_call_count", self._tool_call_count)
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.debug(f"Error finalizing agent span: {e}")
|
|
205
|
+
finally:
|
|
206
|
+
self._span.end()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opentelemetry-instrumentation-claude-agent-sdk",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"projectType": "library",
|
|
5
|
+
"sourceRoot": "packages/moda-claude-agent-sdk/opentelemetry",
|
|
6
|
+
"targets": {
|
|
7
|
+
"lock": {
|
|
8
|
+
"executor": "nx:run-commands",
|
|
9
|
+
"options": {
|
|
10
|
+
"command": "uv lock",
|
|
11
|
+
"cwd": "packages/moda-claude-agent-sdk"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"add": {
|
|
15
|
+
"executor": "@nxlv/python:add",
|
|
16
|
+
"options": {}
|
|
17
|
+
},
|
|
18
|
+
"update": {
|
|
19
|
+
"executor": "@nxlv/python:update",
|
|
20
|
+
"options": {}
|
|
21
|
+
},
|
|
22
|
+
"remove": {
|
|
23
|
+
"executor": "@nxlv/python:remove",
|
|
24
|
+
"options": {}
|
|
25
|
+
},
|
|
26
|
+
"build": {
|
|
27
|
+
"executor": "@nxlv/python:build",
|
|
28
|
+
"outputs": [
|
|
29
|
+
"{projectRoot}/dist"
|
|
30
|
+
],
|
|
31
|
+
"options": {
|
|
32
|
+
"outputPath": "packages/moda-claude-agent-sdk/dist",
|
|
33
|
+
"publish": false,
|
|
34
|
+
"lockedVersions": true,
|
|
35
|
+
"bundleLocalDependencies": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"install": {
|
|
39
|
+
"executor": "nx:run-commands",
|
|
40
|
+
"options": {
|
|
41
|
+
"command": "uv sync --all-groups",
|
|
42
|
+
"cwd": "packages/moda-claude-agent-sdk"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"lint": {
|
|
46
|
+
"executor": "nx:run-commands",
|
|
47
|
+
"options": {
|
|
48
|
+
"command": "uv run ruff check .",
|
|
49
|
+
"cwd": "packages/moda-claude-agent-sdk"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"test": {
|
|
53
|
+
"executor": "nx:run-commands",
|
|
54
|
+
"outputs": [
|
|
55
|
+
"{workspaceRoot}/reports/packages/moda-claude-agent-sdk/unittests",
|
|
56
|
+
"{workspaceRoot}/coverage/packages/moda-claude-agent-sdk"
|
|
57
|
+
],
|
|
58
|
+
"options": {
|
|
59
|
+
"command": "uv run pytest tests/",
|
|
60
|
+
"cwd": "packages/moda-claude-agent-sdk"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"build-release": {
|
|
64
|
+
"executor": "nx:run-commands",
|
|
65
|
+
"options": {
|
|
66
|
+
"commands": [
|
|
67
|
+
"chmod +x ../../scripts/build-release.sh",
|
|
68
|
+
"../../scripts/build-release.sh"
|
|
69
|
+
],
|
|
70
|
+
"cwd": "packages/moda-claude-agent-sdk"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"tags": [
|
|
75
|
+
"instrumentation"
|
|
76
|
+
]
|
|
77
|
+
}
|