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.
@@ -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,3 @@
1
+ # moda-claude-agent-sdk
2
+
3
+ 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,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
+ }