diff --git a/.github/workflows/loongsuite_lint_0.yml b/.github/workflows/loongsuite_lint_0.yml index 3c71465c..970b3610 100644 --- a/.github/workflows/loongsuite_lint_0.yml +++ b/.github/workflows/loongsuite_lint_0.yml @@ -70,6 +70,25 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e lint-loongsuite-instrumentation-dashscope + lint-loongsuite-instrumentation-claude-agent-sdk: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e lint-loongsuite-instrumentation-claude-agent-sdk + lint-loongsuite-instrumentation-mem0: name: LoongSuite loongsuite-instrumentation-mem0 runs-on: ubuntu-latest diff --git a/.github/workflows/loongsuite_test_0.yml b/.github/workflows/loongsuite_test_0.yml index 36c280c0..17cfd401 100644 --- a/.github/workflows/loongsuite_test_0.yml +++ b/.github/workflows/loongsuite_test_0.yml @@ -374,6 +374,158 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-instrumentation-dashscope-latest -- -ra + py310-test-loongsuite-instrumentation-claude-agent-sdk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-oldest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py310-test-loongsuite-instrumentation-claude-agent-sdk-oldest -- -ra + + py310-test-loongsuite-instrumentation-claude-agent-sdk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-latest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py310-test-loongsuite-instrumentation-claude-agent-sdk-latest -- -ra + + py311-test-loongsuite-instrumentation-claude-agent-sdk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-oldest 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py311-test-loongsuite-instrumentation-claude-agent-sdk-oldest -- -ra + + py311-test-loongsuite-instrumentation-claude-agent-sdk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-latest 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py311-test-loongsuite-instrumentation-claude-agent-sdk-latest -- -ra + + py312-test-loongsuite-instrumentation-claude-agent-sdk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-oldest 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py312-test-loongsuite-instrumentation-claude-agent-sdk-oldest -- -ra + + py312-test-loongsuite-instrumentation-claude-agent-sdk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-latest 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py312-test-loongsuite-instrumentation-claude-agent-sdk-latest -- -ra + + py313-test-loongsuite-instrumentation-claude-agent-sdk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-oldest 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-instrumentation-claude-agent-sdk-oldest -- -ra + + py313-test-loongsuite-instrumentation-claude-agent-sdk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-claude-agent-sdk-latest 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-instrumentation-claude-agent-sdk-latest -- -ra + py310-test-loongsuite-instrumentation-mem0-oldest_ubuntu-latest: name: LoongSuite loongsuite-instrumentation-mem0-oldest 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/CHANGELOG.md b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/CHANGELOG.md new file mode 100644 index 00000000..26f1bc59 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added + +- Initial implementation of Claude Agent SDK instrumentation +- Support for agent query sessions via Hooks mechanism +- Support for tool execution tracing (PreToolUse/PostToolUse hooks) +- Integration with `opentelemetry-util-genai` ExtendedTelemetryHandler +- Span attributes following OpenTelemetry GenAI Semantic Conventions +- Support for Alibaba Cloud DashScope Anthropic-compatible API + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/LICENSE b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/README.rst b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/README.rst new file mode 100644 index 00000000..615c2fc4 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/README.rst @@ -0,0 +1,159 @@ +LoongSuite Instrumentation for Claude Agent SDK +================================================ + +This library provides automatic instrumentation for the `Claude Agent SDK +`_, adding OpenTelemetry +tracing and metrics for agent conversations, LLM calls, and tool executions. + +.. note:: + This package is currently in development and must be installed from source. + PyPI release is planned for future versions. + +Installation +------------ + +:: + + pip install opentelemetry-distro opentelemetry-exporter-otlp + opentelemetry-bootstrap -a install + + pip install claude-agent-sdk + + # Install this instrumentation + pip install ./instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk + + # Note: This instrumentation uses ExtendedTelemetryHandler from opentelemetry-util-genai + pip install ./util/opentelemetry-util-genai + +Usage +----- + +Auto-instrumentation +~~~~~~~~~~~~~~~~~~~~ + +Use the ``opentelemetry-instrument`` wrapper: + +:: + + opentelemetry-instrument \ + --traces_exporter console \ + --metrics_exporter console \ + python your_claude_agent_app.py + +Manual Instrumentation +~~~~~~~~~~~~~~~~~~~~~~ + +:: + + from opentelemetry.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor + from claude_agent_sdk import query + from claude_agent_sdk.types import ClaudeAgentOptions + + ClaudeAgentSDKInstrumentor().instrument() + + options = ClaudeAgentOptions(model="claude-3-5-sonnet-20241022", max_turns=5) + + async def run_agent(): + async for message in query(prompt="Hello!", options=options): + print(message) + + ClaudeAgentSDKInstrumentor().uninstrument() + +Configuration +------------- + +Export to OTLP Backend +~~~~~~~~~~~~~~~~~~~~~~ + +:: + + export OTEL_SERVICE_NAME=my-claude-agent-app + export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf + export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= + export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT= + + opentelemetry-instrument python your_app.py + +Content Capture +~~~~~~~~~~~~~~~ + +Control message content capture using environment variables: + +:: + + # Enable experimental GenAI semantic conventions + export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental + + # Capture content in spans only + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY + + # Capture content in events only + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=EVENT_ONLY + + # Capture in both spans and events + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT + + # Disable content capture (default) + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT + +Using with Alibaba Cloud DashScope +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This instrumentation works with Alibaba Cloud's DashScope service via the +Anthropic-compatible API endpoint: + +:: + + import os + + # Set environment variables for DashScope + os.environ["ANTHROPIC_BASE_URL"] = "https://dashscope.aliyuncs.com/apps/anthropic" + os.environ["ANTHROPIC_API_KEY"] = "your-dashscope-api-key" + +Supported Components +-------------------- + +- **Agent Sessions**: ``query`` function for conversational agent interactions +- **Tool Executions**: Automatic tracing via PreToolUse/PostToolUse hooks +- **Token Tracking**: Via MessageComplete hook +- **Context Compaction**: Via PreCompact hook + +Visualization +------------- + +Export telemetry data to: + +- `Alibaba Cloud Managed Service for OpenTelemetry `_ +- Any OpenTelemetry-compatible backend (Jaeger, Zipkin, etc.) + +Span Hierarchy +-------------- + +:: + + invoke_agent (parent span) + ├── User prompt event + ├── execute_tool (child span) + │ ├── gen_ai.tool.input.* attributes + │ └── gen_ai.tool.response.* attributes + ├── execute_tool (child span) + │ └── ... + └── Agent completed event + +Examples +-------- + +See the `main README `_ for complete usage examples. + +License +------- + +Apache License 2.0 + +References +---------- + +- `OpenTelemetry GenAI Semantic Conventions `_ +- `Claude Agent SDK `_ +- `Alibaba Cloud DashScope Anthropic API `_ + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/pyproject.toml new file mode 100644 index 00000000..aefdbdf0 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "loongsuite-instrumentation-claude-agent-sdk" +dynamic = ["version"] +description = "LoongSuite Claude Agent SDK instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "LoongSuite Python Agent Authors", email = "qp467389@alibaba-inc.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api ~= 1.37", + "opentelemetry-instrumentation ~= 0.58b0", + "opentelemetry-semantic-conventions ~= 0.58b0", + # Note: opentelemetry-util-genai should be installed from local source + # for extended features (ExtendedTelemetryHandler) + "opentelemetry-util-genai", +] + +[project.optional-dependencies] +instruments = [ + "claude-agent-sdk >= 0.1.0", +] + +[project.entry-points.opentelemetry_instrumentor] +claude_agent_sdk = "opentelemetry.instrumentation.claude_agent_sdk:ClaudeAgentSDKInstrumentor" + +[project.urls] +Homepage = "https://github.com/alibaba/loongsuite-python-agent" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/claude_agent_sdk/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +markers = [ + "requires_cli: marks tests that require Claude CLI executable (skipped in CI)", +] + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/__init__.py new file mode 100644 index 00000000..175296ec --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/__init__.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/__init__.py new file mode 100644 index 00000000..175296ec --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/__init__.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py new file mode 100644 index 00000000..1c8e23f4 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/__init__.py @@ -0,0 +1,183 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OpenTelemetry Claude Agent SDK Instrumentation +============================================== + +This package provides automatic instrumentation for the Claude Agent SDK, +capturing telemetry data for agent sessions and tool executions. + +Usage +----- + +Basic instrumentation:: + + from opentelemetry.instrumentation.claude_agent_sdk import ClaudeAgentSDKInstrumentor + + # Apply instrumentation + ClaudeAgentSDKInstrumentor().instrument() + + # Your Claude Agent SDK code works as normal + from claude_agent_sdk import ClaudeSDKClient + + async with ClaudeSDKClient() as client: + await client.query(prompt="Hello!") + async for message in client.receive_response(): + print(message) + +The instrumentation automatically captures: + +- Agent session spans (invoke_agent) +- Tool execution spans (execute_tool) +- Token usage (input/output tokens) + +""" + +import logging +from typing import Any, Collection, Optional + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.claude_agent_sdk.package import _instruments +from opentelemetry.instrumentation.claude_agent_sdk.patch import ( + wrap_claude_client_init, + wrap_claude_client_query, + wrap_claude_client_receive_response, + wrap_query, +) +from opentelemetry.instrumentation.claude_agent_sdk.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler + +logger = logging.getLogger(__name__) + + +class ClaudeAgentSDKInstrumentor(BaseInstrumentor): + """ + Instrumentor for Claude Agent SDK. + """ + + _handler: Optional[ExtendedTelemetryHandler] = None + + def __init__(self): + super().__init__() + + def instrumentation_dependencies(self) -> Collection[str]: + """Return the dependencies required for this instrumentation.""" + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + """ + Apply instrumentation to Claude Agent SDK. + + Kwargs: + tracer_provider: Optional TracerProvider to use + meter_provider: Optional MeterProvider to use + logger_provider: Optional LoggerProvider to use + """ + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + logger_provider = kwargs.get("logger_provider") + + # Create ExtendedTelemetryHandler + self._handler = ExtendedTelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + + # Wrap ClaudeSDKClient.__init__ + try: + wrap_function_wrapper( + module="claude_agent_sdk", + name="ClaudeSDKClient.__init__", + wrapper=lambda wrapped, + instance, + args, + kwargs: wrap_claude_client_init( + wrapped, instance, args, kwargs, handler=self._handler + ), + ) + except Exception as e: + logger.warning( + f"Failed to instrument ClaudeSDKClient.__init__: {e}" + ) + + # Wrap ClaudeSDKClient.query + try: + wrap_function_wrapper( + module="claude_agent_sdk", + name="ClaudeSDKClient.query", + wrapper=lambda wrapped, + instance, + args, + kwargs: wrap_claude_client_query( + wrapped, instance, args, kwargs, handler=self._handler + ), + ) + except Exception as e: + logger.warning(f"Failed to instrument ClaudeSDKClient.query: {e}") + + # Wrap ClaudeSDKClient.receive_response + try: + wrap_function_wrapper( + module="claude_agent_sdk", + name="ClaudeSDKClient.receive_response", + wrapper=lambda wrapped, + instance, + args, + kwargs: wrap_claude_client_receive_response( + wrapped, instance, args, kwargs, handler=self._handler + ), + ) + except Exception as e: + logger.warning( + f"Failed to instrument ClaudeSDKClient.receive_response: {e}" + ) + + # Wrap standalone query() function + try: + wrap_function_wrapper( + module="claude_agent_sdk", + name="query", + wrapper=lambda wrapped, instance, args, kwargs: wrap_query( + wrapped, instance, args, kwargs, handler=self._handler + ), + ) + except Exception as e: + logger.warning(f"Failed to instrument claude_agent_sdk.query: {e}") + + def _uninstrument(self, **kwargs: Any) -> None: + """Remove instrumentation from Claude Agent SDK.""" + try: + import claude_agent_sdk # noqa: PLC0415 + + # Unwrap all instrumented methods + unwrap(claude_agent_sdk.ClaudeSDKClient, "__init__") + unwrap(claude_agent_sdk.ClaudeSDKClient, "query") + unwrap(claude_agent_sdk.ClaudeSDKClient, "receive_response") + unwrap(claude_agent_sdk, "query") + + except Exception as e: + logger.warning(f"Failed to uninstrument Claude Agent SDK: {e}") + + ClaudeAgentSDKInstrumentor._handler = None + + +__all__ = [ + "__version__", + "ClaudeAgentSDKInstrumentor", +] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/context.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/context.py new file mode 100644 index 00000000..4b7d160f --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/context.py @@ -0,0 +1,53 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Thread-local storage utilities for Claude Agent SDK tracing. + +This module provides thread-local storage for the parent invocation context, +which is used by hooks to maintain trace context when async context +propagation is broken (Claude's async event loop breaks OpenTelemetry context). +""" + +import threading +from typing import Any, Optional + +# Thread-local store for passing the parent invocation into hooks. +# Claude's async event loop by default breaks OpenTelemetry context propagation. +# The parent invocation is threaded via thread-local as a fallback. +_thread_local = threading.local() + + +def set_parent_invocation(invocation: Any) -> None: + """Set the parent agent invocation in thread-local storage. + + Args: + invocation: InvokeAgentInvocation or ExecuteToolInvocation instance + """ + _thread_local.parent_invocation = invocation + + +def clear_parent_invocation() -> None: + """Clear the parent invocation from thread-local storage.""" + if hasattr(_thread_local, "parent_invocation"): + delattr(_thread_local, "parent_invocation") + + +def get_parent_invocation() -> Optional[Any]: + """Get the parent invocation from thread-local storage. + + Returns: + Parent invocation or None if not set + """ + return getattr(_thread_local, "parent_invocation", None) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/hooks.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/hooks.py new file mode 100644 index 00000000..a3d73ea1 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/hooks.py @@ -0,0 +1,260 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple + +from opentelemetry import context as otel_context +from opentelemetry.instrumentation.claude_agent_sdk.context import ( + get_parent_invocation, +) +from opentelemetry.trace import set_span_in_context +from opentelemetry.util.genai.extended_handler import ( + get_extended_telemetry_handler, +) +from opentelemetry.util.genai.extended_types import ExecuteToolInvocation +from opentelemetry.util.genai.types import Error + +if TYPE_CHECKING: + from claude_agent_sdk import ( + HookContext, + HookInput, + HookJSONOutput, + ) + +logger = logging.getLogger(__name__) + +# Storage for correlating PreToolUse and PostToolUse events +# Key: tool_use_id, Value: (tool_invocation, handler) +_active_tool_runs: Dict[str, Tuple[ExecuteToolInvocation, Any]] = {} + +# Storage for tool or subagent runs managed by client +# Key: tool_use_id, Value: tool_invocation +_client_managed_runs: Dict[str, ExecuteToolInvocation] = {} + + +async def pre_tool_use_hook( + input_data: "HookInput", + tool_use_id: Optional[str], + context: "HookContext", +) -> "HookJSONOutput": + """Trace tool execution before it starts. + + This hook is called by Claude Agent SDK before executing a tool. + It creates an execute_tool span as a child of the current agent span. + + Args: + input_data: Contains `tool_name`, `tool_input`, `session_id` + tool_use_id: Unique identifier for this tool invocation + context: Hook context (currently contains only signal) + + Returns: + Hook output (empty dict allows execution to proceed) + """ + if not tool_use_id: + return {} + + # Skip if this tool run is already managed by the client + if tool_use_id in _client_managed_runs: + return {} + + tool_name: str = str(input_data.get("tool_name", "unknown_tool")) + tool_input = input_data.get("tool_input", {}) + session_id = input_data.get("session_id", "") + + try: + handler = get_extended_telemetry_handler() + parent_invocation = get_parent_invocation() + + if not parent_invocation: + return {} + + # Create tool invocation following ExecuteToolInvocation semantic conventions + # Map to standard fields strictly, avoiding custom attributes + tool_invocation = ExecuteToolInvocation( + tool_name=tool_name, + tool_call_id=tool_use_id, + tool_call_arguments=tool_input, # Standard field: tool call arguments + tool_description=tool_name, # Use tool_name directly + attributes={ + # Only include Claude Agent SDK-specific attributes that cannot map to standard fields + "tool.session_id": session_id, + } + if session_id + else {}, + ) + + # Explicitly create tool span as child of parent invocation span + # This avoids relying on broken async context propagation + if parent_invocation and parent_invocation.span: + # Create child span in parent's context + ctx = set_span_in_context(parent_invocation.span) + token = otel_context.attach(ctx) + + try: + handler.start_execute_tool(tool_invocation) + finally: + # Detach after starting span + try: + otel_context.detach(token) + except Exception: + pass # Ignore detach errors + else: + # Fallback to auto-parenting (may not work due to broken context) + handler.start_execute_tool(tool_invocation) + + _active_tool_runs[tool_use_id] = (tool_invocation, handler) + + except Exception as e: + logger.warning( + f"Error in PreToolUse hook for {tool_name}: {e}", exc_info=True + ) + + return {} + + +async def post_tool_use_hook( + input_data: "HookInput", + tool_use_id: Optional[str], + context: "HookContext", +) -> "HookJSONOutput": + """Trace tool execution after it completes. + + This hook is called by Claude Agent SDK after tool execution completes. + It ends the corresponding execute_tool span and records the result. + + Args: + input_data: Contains `tool_name`, `tool_input`, `tool_response`, `session_id`, etc. + tool_use_id: Unique identifier for this tool invocation + context: Hook context (currently contains only signal) + + Returns: + Hook output (empty dict by default) + """ + if not tool_use_id: + return {} + + tool_name: str = str(input_data.get("tool_name", "unknown_tool")) + tool_response = input_data.get("tool_response") + + # Check if this is a client-managed run + client_invocation = _client_managed_runs.pop(tool_use_id, None) + if client_invocation: + # This run is managed by the client (subagent session or its tools) + try: + handler = get_extended_telemetry_handler() + + # Set response (will be auto-formatted to gen_ai.tool.call.result by telemetry handler) + client_invocation.tool_call_result = tool_response + + is_error = False + if isinstance(tool_response, dict): + is_error_value = tool_response.get("is_error") + is_error = is_error_value is True + + if is_error: + error_msg = ( + str(tool_response) + if tool_response + else "Tool execution error" + ) + handler.fail_execute_tool( + client_invocation, + Error(message=error_msg, type=RuntimeError), + ) + else: + handler.stop_execute_tool(client_invocation) + + except Exception as e: + logger.warning( + f"Failed to complete client-managed run: {e}", exc_info=True + ) + return {} + + try: + run_info = _active_tool_runs.pop(tool_use_id, None) + if not run_info: + return {} + + tool_invocation, handler = run_info + + # Set response (will be auto-formatted to gen_ai.tool.call.result by telemetry handler) + tool_invocation.tool_call_result = tool_response + + is_error = False + if isinstance(tool_response, dict): + is_error_value = tool_response.get("is_error") + is_error = is_error_value is True + + if is_error: + error_msg = ( + str(tool_response) if tool_response else "Tool execution error" + ) + handler.fail_execute_tool( + tool_invocation, Error(message=error_msg, type=RuntimeError) + ) + else: + handler.stop_execute_tool(tool_invocation) + + except Exception as e: + logger.warning( + f"Error in PostToolUse hook for {tool_name}: {e}", exc_info=True + ) + + return {} + + +def clear_active_tool_runs() -> None: + """Clear all active tool runs. + + This should be called when a conversation ends to avoid memory leaks + and to clean up any orphaned tool runs. + """ + global _active_tool_runs, _client_managed_runs + + try: + handler = get_extended_telemetry_handler() + except Exception: + _active_tool_runs.clear() + _client_managed_runs.clear() + return + + # End any orphaned client-managed runs + for tool_use_id, tool_invocation in list(_client_managed_runs.items()): + try: + handler.fail_execute_tool( + tool_invocation, + Error( + message="Client-managed run not completed (conversation ended)", + type=RuntimeError, + ), + ) + except Exception: + pass + + # End any orphaned tool runs + for tool_use_id, (tool_invocation, _) in list(_active_tool_runs.items()): + try: + handler.fail_execute_tool( + tool_invocation, + Error( + message="Tool run not completed (conversation ended)", + type=RuntimeError, + ), + ) + except Exception: + pass + + _active_tool_runs.clear() + _client_managed_runs.clear() diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/package.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/package.py new file mode 100644 index 00000000..b9783587 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/package.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +_instruments = ("claude-agent-sdk >= 0.1.0",) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py new file mode 100644 index 00000000..b10e97f0 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/patch.py @@ -0,0 +1,707 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Patch functions for Claude Agent SDK instrumentation.""" + +import logging +import time +from typing import Any, Dict, List, Optional + +from claude_agent_sdk import HookMatcher +from claude_agent_sdk.types import ClaudeAgentOptions + +from opentelemetry import context as otel_context +from opentelemetry.instrumentation.claude_agent_sdk.context import ( + clear_parent_invocation, + set_parent_invocation, +) +from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( + _client_managed_runs, + clear_active_tool_runs, + post_tool_use_hook, + pre_tool_use_hook, +) +from opentelemetry.instrumentation.claude_agent_sdk.utils import ( + extract_usage_from_result_message, + get_model_from_options_or_env, + infer_provider_from_base_url, +) +from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler +from opentelemetry.util.genai.extended_types import ( + ExecuteToolInvocation, + InvokeAgentInvocation, +) +from opentelemetry.util.genai.types import ( + Error, + InputMessage, + LLMInvocation, + OutputMessage, + Text, + ToolCall, +) + +logger = logging.getLogger(__name__) + + +def _extract_message_parts(msg: Any) -> List[Any]: + """Extract parts (text + tool calls) from an AssistantMessage.""" + parts = [] + if not hasattr(msg, "content"): + return parts + + for block in msg.content: + block_type = type(block).__name__ + if block_type == "TextBlock": + parts.append(Text(content=getattr(block, "text", ""))) + elif block_type == "ToolUseBlock": + tool_call = ToolCall( + id=getattr(block, "id", ""), + name=getattr(block, "name", ""), + arguments=getattr(block, "input", {}), + ) + parts.append(tool_call) + + return parts + + +def _create_tool_spans_from_message( + msg: Any, + handler: ExtendedTelemetryHandler, + exclude_tool_names: Optional[List[str]] = None, +) -> None: + """Create tool execution spans from ToolUseBlocks in an AssistantMessage.""" + if not hasattr(msg, "content"): + return + + exclude_tool_names = exclude_tool_names or [] + + for block in msg.content: + if type(block).__name__ != "ToolUseBlock": + continue + + tool_use_id = getattr(block, "id", None) + tool_name = getattr(block, "name", "unknown_tool") + tool_input = getattr(block, "input", {}) + + if not tool_use_id or tool_name in exclude_tool_names: + continue + + try: + tool_invocation = ExecuteToolInvocation( + tool_name=tool_name, + tool_call_id=tool_use_id, + tool_call_arguments=tool_input, + tool_description=tool_name, + ) + handler.start_execute_tool(tool_invocation) + _client_managed_runs[tool_use_id] = tool_invocation + except Exception as e: + logger.warning(f"Failed to create tool span for {tool_name}: {e}") + + +def _close_tool_spans_from_message( + msg: Any, + handler: ExtendedTelemetryHandler, +) -> List[str]: + """Close tool execution spans from ToolResultBlocks in a UserMessage.""" + user_text_parts = [] + + if not hasattr(msg, "content"): + return user_text_parts + + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "ToolResultBlock": + tool_use_id = getattr(block, "tool_use_id", None) + if tool_use_id and tool_use_id in _client_managed_runs: + tool_invocation = _client_managed_runs.pop(tool_use_id) + + # Set tool response + tool_content = getattr(block, "content", None) + is_error_value = getattr(block, "is_error", None) + is_error = is_error_value is True + + tool_invocation.tool_call_result = tool_content + + # Complete span + if is_error: + error_msg = ( + str(tool_content) + if tool_content + else "Tool execution error" + ) + handler.fail_execute_tool( + tool_invocation, + Error(message=error_msg, type=RuntimeError), + ) + else: + handler.stop_execute_tool(tool_invocation) + + elif block_type == "TextBlock": + user_text_parts.append(getattr(block, "text", "")) + + return user_text_parts + + +def _update_token_usage( + agent_invocation: InvokeAgentInvocation, + turn_tracker: "AssistantTurnTracker", + msg: Any, +) -> None: + """Update token usage from a ResultMessage.""" + usage_meta = extract_usage_from_result_message(msg) + if not usage_meta: + return + + # Update agent invocation token usage + if "input_tokens" in usage_meta: + agent_invocation.input_tokens = usage_meta["input_tokens"] + if "output_tokens" in usage_meta: + agent_invocation.output_tokens = usage_meta["output_tokens"] + + # Update current LLM turn token usage + turn_tracker.update_usage( + usage_meta.get("input_tokens"), usage_meta.get("output_tokens") + ) + + +def _process_assistant_message( + msg: Any, + model: str, + prompt: str, + agent_invocation: InvokeAgentInvocation, + turn_tracker: "AssistantTurnTracker", + handler: ExtendedTelemetryHandler, + collected_messages: List[Dict[str, Any]], + process_subagents: bool = False, + subagent_sessions: Optional[Dict[str, InvokeAgentInvocation]] = None, +) -> None: + """Process AssistantMessage: create LLM turn, extract parts, create tool spans.""" + parts = _extract_message_parts(msg) + has_text_content = any(isinstance(p, Text) for p in parts) + + if has_text_content: + # This is the start of a new LLM response (with text content) + message_arrival_time = time.time() + + turn_tracker.start_llm_turn( + msg, + model, + prompt, + collected_messages, + provider=infer_provider_from_base_url(), + message_arrival_time=message_arrival_time, + ) + + if parts: + turn_tracker.add_assistant_output(parts) + output_msg = OutputMessage( + role="assistant", parts=parts, finish_reason="stop" + ) + agent_invocation.output_messages.append(output_msg) + + text_parts = [p.content for p in parts if isinstance(p, Text)] + if text_parts: + collected_messages.append( + {"role": "assistant", "content": " ".join(text_parts)} + ) + + else: + # This is a tool-only message, part of the current LLM turn + # Append it to the current LLM invocation's output + if parts and turn_tracker.current_llm_invocation: + turn_tracker.add_assistant_output(parts) + output_msg = OutputMessage( + role="assistant", parts=parts, finish_reason="stop" + ) + agent_invocation.output_messages.append(output_msg) + + turn_tracker.close_llm_turn() + + if process_subagents and subagent_sessions is not None: + _handle_task_subagents( + msg, agent_invocation, subagent_sessions, handler + ) + + exclude_tools = ["Task"] if process_subagents else [] + _create_tool_spans_from_message( + msg, handler, exclude_tool_names=exclude_tools + ) + + +def _process_user_message( + msg: Any, + turn_tracker: "AssistantTurnTracker", + handler: ExtendedTelemetryHandler, + collected_messages: List[Dict[str, Any]], +) -> None: + """Process UserMessage: close tool spans, collect message content, mark next LLM start.""" + user_text_parts = _close_tool_spans_from_message(msg, handler) + + if user_text_parts: + user_content = " ".join(user_text_parts) + collected_messages.append({"role": "user", "content": user_content}) + + # Always mark next LLM start when UserMessage arrives + turn_tracker.mark_next_llm_start() + + +def _process_result_message( + msg: Any, + agent_invocation: InvokeAgentInvocation, + turn_tracker: "AssistantTurnTracker", +) -> None: + """Process ResultMessage: update session_id and token usage.""" + if hasattr(msg, "session_id") and msg.session_id: + agent_invocation.conversation_id = msg.session_id + if agent_invocation.span: + agent_invocation.span.set_attribute( + "gen_ai.conversation.id", msg.session_id + ) + + _update_token_usage(agent_invocation, turn_tracker, msg) + + +class AssistantTurnTracker: + """Track LLM invocations (assistant turns) in a Claude Agent conversation.""" + + def __init__( + self, + handler: ExtendedTelemetryHandler, + query_start_time: Optional[float] = None, + ): + self.handler = handler + self.current_llm_invocation: Optional[LLMInvocation] = None + self.last_closed_llm_invocation: Optional[LLMInvocation] = None + self.next_llm_start_time: Optional[float] = query_start_time + + def start_llm_turn( + self, + msg: Any, + model: str, + prompt: str, + collected_messages: List[Dict[str, Any]], + provider: str = "anthropic", + message_arrival_time: Optional[float] = None, + ) -> Optional[LLMInvocation]: + """Start a new LLM invocation span with pre-recorded start time. + + Args: + message_arrival_time: The time when the AssistantMessage arrived. + If next_llm_start_time is set (from previous UserMessage), use that. + Otherwise, use message_arrival_time or fall back to current time. + """ + # Priority: next_llm_start_time > message_arrival_time > current time + start_time = ( + self.next_llm_start_time or message_arrival_time or time.time() + ) + + if self.current_llm_invocation: + self.handler.stop_llm(self.current_llm_invocation) + self.last_closed_llm_invocation = self.current_llm_invocation + self.current_llm_invocation = None + + self.next_llm_start_time = None + + # Build input_messages from prompt + collected messages + input_messages = [] + + if prompt: + input_messages.append( + InputMessage(role="user", parts=[Text(content=prompt)]) + ) + + for hist_msg in collected_messages: + role = hist_msg.get("role", "user") + content = hist_msg.get("content", "") + if isinstance(content, str) and content: + input_messages.append( + InputMessage(role=role, parts=[Text(content=content)]) + ) + + llm_invocation = LLMInvocation( + provider=provider, + request_model=model, + input_messages=input_messages, + ) + + self.handler.start_llm(llm_invocation) + + # Override span start time + if llm_invocation.span and start_time: + start_time_ns = int(start_time * 1_000_000_000) + try: + if hasattr(llm_invocation.span, "_start_time"): + llm_invocation.span._start_time = start_time_ns # type: ignore + except Exception as e: + logger.warning(f"Failed to set span start time: {e}") + + self.current_llm_invocation = llm_invocation + return llm_invocation + + def add_assistant_output(self, parts: List[Any]) -> None: + """Add output message parts to current LLM invocation.""" + if not self.current_llm_invocation or not parts: + return + + output_msg = OutputMessage( + role="assistant", parts=parts, finish_reason="stop" + ) + self.current_llm_invocation.output_messages.append(output_msg) + + def add_user_message(self, content: str) -> None: + """Mark next LLM start time.""" + self.mark_next_llm_start() + + def mark_next_llm_start(self) -> None: + """Mark the start time for the next LLM invocation.""" + self.next_llm_start_time = time.time() + + def update_usage( + self, input_tokens: Optional[int], output_tokens: Optional[int] + ) -> None: + """Update token usage for current or last closed LLM invocation.""" + target_invocation = ( + self.current_llm_invocation or self.last_closed_llm_invocation + ) + if not target_invocation: + return + + if input_tokens is not None: + target_invocation.input_tokens = input_tokens + if output_tokens is not None: + target_invocation.output_tokens = output_tokens + + def close_llm_turn(self) -> None: + """Close the current LLM invocation span.""" + if self.current_llm_invocation: + self.handler.stop_llm(self.current_llm_invocation) + self.last_closed_llm_invocation = self.current_llm_invocation + self.current_llm_invocation = None + + def close(self) -> None: + """Close any open LLM invocation (cleanup fallback).""" + if self.current_llm_invocation: + self.handler.stop_llm(self.current_llm_invocation) + self.current_llm_invocation = None + + +def _inject_tracing_hooks(options: Any) -> None: + """Inject OpenTelemetry tracing hooks into ClaudeAgentOptions.""" + if not hasattr(options, "hooks"): + return + + if options.hooks is None: + options.hooks = {} + + if "PreToolUse" not in options.hooks: + options.hooks["PreToolUse"] = [] + + if "PostToolUse" not in options.hooks: + options.hooks["PostToolUse"] = [] + + try: + otel_pre_matcher = HookMatcher(matcher=None, hooks=[pre_tool_use_hook]) + otel_post_matcher = HookMatcher( + matcher=None, hooks=[post_tool_use_hook] + ) + + options.hooks["PreToolUse"].insert(0, otel_pre_matcher) + options.hooks["PostToolUse"].insert(0, otel_post_matcher) + except Exception as e: + logger.warning(f"Failed to inject tracing hooks: {e}") + + +def wrap_claude_client_init(wrapped, instance, args, kwargs, handler=None): + """Wrapper for ClaudeSDKClient.__init__ to inject tracing hooks.""" + if handler is None: + logger.warning("Handler not provided, skipping instrumentation") + return wrapped(*args, **kwargs) + + options = kwargs.get("options") or (args[0] if args else None) + if options: + _inject_tracing_hooks(options) + + result = wrapped(*args, **kwargs) + + instance._otel_handler = handler + instance._otel_prompt = None + + return result + + +def wrap_claude_client_query(wrapped, instance, args, kwargs, handler=None): + """Wrapper for ClaudeSDKClient.query to capture prompt.""" + if hasattr(instance, "_otel_prompt"): + instance._otel_prompt = str( + kwargs.get("prompt") or (args[0] if args else "") + ) + + return wrapped(*args, **kwargs) + + +def _handle_task_subagents( + msg: Any, + agent_invocation: InvokeAgentInvocation, + subagent_sessions: Dict[str, InvokeAgentInvocation], + handler: ExtendedTelemetryHandler, +) -> None: + """Process Task tool uses (subagents) in an assistant message.""" + if not hasattr(msg, "content"): + return + + parent_tool_use_id = getattr(msg, "parent_tool_use_id", None) + + for block in msg.content: + if type(block).__name__ != "ToolUseBlock": + continue + + try: + tool_use_id = getattr(block, "id", None) + tool_name = getattr(block, "name", "unknown_tool") + tool_input = getattr(block, "input", {}) + + if not tool_use_id: + continue + + # Only handle Task subagents here (Regular tools are handled by hooks) + if tool_name == "Task" and not parent_tool_use_id: + # Extract subagent name from input + subagent_name = ( + tool_input.get("subagent_type") + or ( + tool_input.get("description", "").split()[0] + if tool_input.get("description") + else None + ) + or "unknown-agent" + ) + + # Create subagent session span + subagent_invocation = InvokeAgentInvocation( + provider=infer_provider_from_base_url(), + agent_name=subagent_name, + request_model=agent_invocation.request_model, + conversation_id="", + input_messages=[ + InputMessage( + role="user", parts=[Text(content=str(tool_input))] + ) + ], + attributes={ + "subagent_type": tool_input.get("subagent_type", ""), + "parent_tool_use_id": parent_tool_use_id or "", + }, + ) + + handler.start_invoke_agent(subagent_invocation) + subagent_sessions[tool_use_id] = subagent_invocation + + # Mark as client-managed so hooks don't duplicate it + _client_managed_runs[tool_use_id] = ExecuteToolInvocation( + tool_name="Task", + tool_call_id=tool_use_id, + tool_call_arguments=tool_input, + ) + + except Exception as e: + logger.warning(f"Failed to create subagent session: {e}") + + +async def wrap_claude_client_receive_response( + wrapped, instance, args, kwargs, handler=None +): + """Wrapper for ClaudeSDKClient.receive_response to trace agent invocation.""" + if handler is None: + handler = getattr(instance, "_otel_handler", None) + + if handler is None: + logger.warning("Handler not available, skipping instrumentation") + async for msg in wrapped(*args, **kwargs): + yield msg + return + + prompt = getattr(instance, "_otel_prompt", "") or "" + model = "unknown" + if hasattr(instance, "options") and instance.options: + model = getattr(instance.options, "model", "unknown") + + agent_invocation = InvokeAgentInvocation( + provider=infer_provider_from_base_url(), + agent_name="claude-agent", + request_model=model, + conversation_id="", + input_messages=[ + InputMessage(role="user", parts=[Text(content=prompt)]) + ] + if prompt + else [], + ) + + # Clear context to create a new root trace for each independent query + otel_context.attach(otel_context.Context()) + handler.start_invoke_agent(agent_invocation) + set_parent_invocation(agent_invocation) + + query_start_time = time.time() + turn_tracker = AssistantTurnTracker( + handler, query_start_time=query_start_time + ) + + collected_messages: List[Dict[str, Any]] = [] + subagent_sessions: Dict[str, InvokeAgentInvocation] = {} + + try: + async for msg in wrapped(*args, **kwargs): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage": + _process_assistant_message( + msg, + model, + prompt, + agent_invocation, + turn_tracker, + handler, + collected_messages, + process_subagents=True, + subagent_sessions=subagent_sessions, + ) + + elif msg_type == "UserMessage": + _process_user_message( + msg, turn_tracker, handler, collected_messages + ) + + elif msg_type == "ResultMessage": + _process_result_message(msg, agent_invocation, turn_tracker) + + yield msg + + handler.stop_invoke_agent(agent_invocation) + + for subagent_invocation in subagent_sessions.values(): + try: + handler.stop_invoke_agent(subagent_invocation) + except Exception as e: + logger.warning(f"Failed to complete subagent session: {e}") + + except Exception as e: + error_msg = str(e) + if agent_invocation.span: + agent_invocation.span.set_attribute("error.type", type(e).__name__) + agent_invocation.span.set_attribute("error.message", error_msg) + handler.fail_invoke_agent( + agent_invocation, error=Error(message=error_msg, type=type(e)) + ) + raise + finally: + turn_tracker.close() + clear_active_tool_runs() + clear_parent_invocation() + + +async def wrap_query(wrapped, instance, args, kwargs, handler=None): + """Wrapper for claude_agent_sdk.query() standalone function.""" + if handler is None: + logger.warning("Handler not provided, skipping instrumentation") + async for message in wrapped(*args, **kwargs): + yield message + return + + prompt = kwargs.get("prompt") or (args[0] if args else "") + options = kwargs.get("options") + + if options: + _inject_tracing_hooks(options) + elif options is None: + try: + options = ClaudeAgentOptions() + _inject_tracing_hooks(options) + kwargs["options"] = options + except Exception as e: + logger.warning(f"Failed to create ClaudeAgentOptions: {e}") + + model = get_model_from_options_or_env(options) + + prompt_str = str(prompt) if isinstance(prompt, str) else "" + agent_invocation = InvokeAgentInvocation( + provider=infer_provider_from_base_url(), + agent_name="claude-agent", + request_model=model, + conversation_id="", + input_messages=[ + InputMessage(role="user", parts=[Text(content=prompt_str)]) + ] + if prompt_str + else [], + ) + + # Clear context to create a new root trace for each independent query + otel_context.attach(otel_context.Context()) + handler.start_invoke_agent(agent_invocation) + set_parent_invocation(agent_invocation) + + query_start_time = time.time() + turn_tracker = AssistantTurnTracker( + handler, query_start_time=query_start_time + ) + + collected_messages: List[Dict[str, Any]] = [] + + try: + async for message in wrapped(*args, **kwargs): + msg_type = type(message).__name__ + + if msg_type == "AssistantMessage": + _process_assistant_message( + message, + model, + prompt_str, + agent_invocation, + turn_tracker, + handler, + collected_messages, + process_subagents=False, + subagent_sessions=None, + ) + + elif msg_type == "UserMessage": + _process_user_message( + message, turn_tracker, handler, collected_messages + ) + + elif msg_type == "ResultMessage": + _process_result_message( + message, agent_invocation, turn_tracker + ) + + yield message + + handler.stop_invoke_agent(agent_invocation) + + except Exception as e: + error_msg = str(e) + if agent_invocation.span: + agent_invocation.span.set_attribute("error.type", type(e).__name__) + agent_invocation.span.set_attribute("error.message", error_msg) + handler.fail_invoke_agent( + agent_invocation, error=Error(message=error_msg, type=type(e)) + ) + raise + finally: + turn_tracker.close() + clear_active_tool_runs() + clear_parent_invocation() diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/utils.py new file mode 100644 index 00000000..a6668399 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/utils.py @@ -0,0 +1,241 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility functions for Claude Agent SDK instrumentation.""" + +import logging +import os +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( + GenAiProviderNameValues, +) +from opentelemetry.util.genai._extended_semconv.gen_ai_extended_attributes import ( + GenAiExtendedProviderNameValues, +) +from opentelemetry.util.genai.types import Text, ToolCall + +logger = logging.getLogger(__name__) + + +def get_model_from_options_or_env(options: Any) -> str: + """ + Get model name from options or environment variables. + """ + model = "unknown" + + if options: + model = getattr(options, "model", None) + + # Key: If options.model is None, read from environment variables + # This mimics Claude CLI behavior: when no --model parameter, CLI reads environment variables + if not model: + model = ( + os.getenv("ANTHROPIC_MODEL") + or os.getenv("ANTHROPIC_SMALL_FAST_MODEL") + or "unknown" + ) + + return model + + +def infer_provider_from_base_url(base_url: Optional[str] = None) -> str: + """ + Infer the provider name from ANTHROPIC_BASE_URL environment variable. + + Only recognizes known providers from OpenTelemetry semantic conventions. + Returns "anthropic" for unknown providers as these are typically Anthropic-compatible API services. + + Args: + base_url: Optional base URL to check. If not provided, reads from ANTHROPIC_BASE_URL env var. + + """ + if base_url is None: + base_url = os.environ.get("ANTHROPIC_BASE_URL", "") + + if not base_url: + return GenAiProviderNameValues.ANTHROPIC.value + + try: + parsed = urlparse(base_url) + hostname = parsed.hostname or "" + + # Check for known providers (order matters: most specific first) + if "dashscope" in hostname or "aliyuncs.com" in hostname: + return GenAiExtendedProviderNameValues.DASHSCOPE.value + + if "moonshot" in hostname: + return GenAiExtendedProviderNameValues.MOONSHOT.value + + return GenAiProviderNameValues.ANTHROPIC.value + + except Exception: + return GenAiProviderNameValues.ANTHROPIC.value + + +def extract_message_parts(message: Any) -> List[Any]: + """ + Extract parts (text + tool calls) from an AssistantMessage. + + Args: + message: AssistantMessage object + + Returns: + List of message parts (Text, ToolCall) + """ + parts = [] + if not hasattr(message, "content"): + return parts + + for block in message.content: + block_type = type(block).__name__ + if block_type == "TextBlock": + parts.append(Text(content=getattr(block, "text", ""))) + elif block_type == "ToolUseBlock": + tool_call = ToolCall( + id=getattr(block, "id", ""), + name=getattr(block, "name", ""), + arguments=getattr(block, "input", {}), + ) + parts.append(tool_call) + + return parts + + +def extract_usage_metadata(usage: Any) -> Dict[str, Any]: + """ + Extract and normalize usage metrics from a Claude usage object or dict. + + Only extracts standard OpenTelemetry fields: input_tokens and output_tokens. + Cache tokens are extracted temporarily for summing into input_tokens. + + Args: + usage: Usage object or dict from Claude API + + Returns: + Dict with input_tokens, output_tokens, and temporary cache token fields + """ + if not usage: + return {} + + get = ( + usage.get + if isinstance(usage, dict) + else lambda k: getattr(usage, k, None) + ) + + def to_int(value): + try: + return int(value) + except (ValueError, TypeError): + return None + + meta: Dict[str, Any] = {} + + # Standard OpenTelemetry fields + if (v := to_int(get("input_tokens"))) is not None: + meta["input_tokens"] = v + if (v := to_int(get("output_tokens"))) is not None: + meta["output_tokens"] = v + + # Temporarily extract cache tokens for summing (will be summed by sum_anthropic_tokens) + if (v := to_int(get("cache_read_input_tokens"))) is not None: + meta["cache_read_input_tokens"] = v + if (v := to_int(get("cache_creation_input_tokens"))) is not None: + meta["cache_creation_input_tokens"] = v + + return meta + + +def sum_anthropic_tokens(usage_metadata: Dict[str, Any]) -> Dict[str, int]: + """ + Sum Anthropic cache tokens into input_tokens. + + Anthropic returns cache tokens separately (cache_read_input_tokens, cache_creation_input_tokens). + This function combines them into the standard input_tokens field for OpenTelemetry reporting. + + Args: + usage_metadata: Usage metadata dict with input_tokens, output_tokens, and optional cache tokens + + Returns: + Dict with only standard OpenTelemetry fields: input_tokens and output_tokens + """ + # Get standard token counts + input_tokens = usage_metadata.get("input_tokens") or 0 + output_tokens = usage_metadata.get("output_tokens") or 0 + + # Get cache tokens (these are temporary fields, not in OpenTelemetry standard) + cache_read = usage_metadata.get("cache_read_input_tokens") or 0 + cache_create = usage_metadata.get("cache_creation_input_tokens") or 0 + + # Sum all input tokens (standard + cache) + total_input_tokens = input_tokens + cache_read + cache_create + + # Return only standard OpenTelemetry fields + return { + "input_tokens": total_input_tokens, + "output_tokens": output_tokens, + } + + +def extract_usage_from_result_message(message: Any) -> Dict[str, Any]: + """Normalize and merge token usage metrics from a `ResultMessage`.""" + if not getattr(message, "usage", None): + return {} + metrics = extract_usage_metadata(message.usage) + return sum_anthropic_tokens(metrics) if metrics else {} + + +def truncate_value(value: Any, max_length: int = 150) -> str: + """ + Truncate a value for display. + + - For strings: truncate with ellipsis + - For lists: show first few items + - For dicts: show truncated version + - For other types: convert to string + """ + if isinstance(value, str): + if len(value) <= max_length: + return value + return value[:max_length] + "..." + + if isinstance(value, list): + if len(value) == 0: + return "[]" + if len(value) <= 3: + items_str = ", ".join( + truncate_value(item, max_length // 3) for item in value + ) + if len(items_str) <= max_length: + return f"[{items_str}]" + first_items = ", ".join( + truncate_value(item, max_length // 4) for item in value[:2] + ) + return f"[{first_items}, ... ({len(value)} items)]" + + if isinstance(value, dict): + if len(value) == 0: + return "{}" + items = [] + for i, (k, v) in enumerate(value.items()): + if i >= 2: + items.append(f"... ({len(value)} keys)") + break + v_str = truncate_value(v, max_length // 3) + items.append(f"{k}: {v_str}") + return "{" + ", ".join(items) + "}" + + return str(value) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/version.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/version.py new file mode 100644 index 00000000..e7844f89 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/src/opentelemetry/instrumentation/claude_agent_sdk/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.0.dev0" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/conftest.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/conftest.py new file mode 100644 index 00000000..e78f5469 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/conftest.py @@ -0,0 +1,138 @@ +"""Unit tests configuration module.""" + +import os + +import pytest + +# Set up environment variables BEFORE any claude_agent_sdk modules are imported +# This is critical because claude_agent_sdk reads environment variables at module import time +if "ANTHROPIC_API_KEY" not in os.environ: + # Use DashScope proxy for testing + os.environ["ANTHROPIC_BASE_URL"] = ( + "https://dashscope.aliyuncs.com/apps/anthropic" + ) + os.environ["ANTHROPIC_API_KEY"] = "test_anthropic_api_key" + os.environ["DASHSCOPE_API_KEY"] = "test_dashscope_api_key" + +# Set GenAI semantic conventions environment variables +os.environ.setdefault( + "OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental" +) +os.environ.setdefault( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_ONLY" +) + +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.instrumentation.claude_agent_sdk import ( + ClaudeAgentSDKInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.util.genai.environment_variables import ( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, +) + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line( + "markers", + "requires_cli: mark test as requiring Claude CLI executable (skipped in CI)", + ) + + +def pytest_collection_modifyitems(config, items): + """Skip tests marked with 'requires_cli' if ANTHROPIC_API_KEY is not set or is mock.""" + # Check if we have a real API key (not the test mock) + api_key = os.environ.get("ANTHROPIC_API_KEY", "") + has_real_api = api_key and api_key != "test_anthropic_api_key" + + skip_cli = pytest.mark.skip( + reason="Requires real ANTHROPIC_API_KEY and Claude CLI (not available in CI)" + ) + + for item in items: + if "requires_cli" in item.keywords and not has_real_api: + item.add_marker(skip_cli) + + +@pytest.fixture(scope="function", name="span_exporter") +def fixture_span_exporter(): + """Create an in-memory span exporter for testing.""" + exporter = InMemorySpanExporter() + yield exporter + + +@pytest.fixture(scope="function", name="tracer_provider") +def fixture_tracer_provider(span_exporter): + """Create a tracer provider with in-memory exporter.""" + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture(scope="function") +def instrument(tracer_provider): + """Instrument Claude Agent SDK for testing.""" + instrumentor = ClaudeAgentSDKInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + + yield instrumentor + + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_no_content(tracer_provider): + """Instrument Claude Agent SDK with message content capture disabled.""" + # Reset global state to allow environment variable changes to take effect + _OpenTelemetrySemanticConventionStability._initialized = False + + os.environ.update( + { + OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental", + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "NO_CONTENT", + } + ) + + instrumentor = ClaudeAgentSDKInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + + yield instrumentor + + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + # Reset global state after test + _OpenTelemetrySemanticConventionStability._initialized = False + + +@pytest.fixture(scope="function") +def instrument_with_content(tracer_provider): + """Instrument Claude Agent SDK with message content capture enabled.""" + # Reset global state to allow environment variable changes to take effect + _OpenTelemetrySemanticConventionStability._initialized = False + + os.environ.update( + { + OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental", + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY", + } + ) + + instrumentor = ClaudeAgentSDKInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + + yield instrumentor + + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + # Reset global state after test + _OpenTelemetrySemanticConventionStability._initialized = False diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.latest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.latest.txt new file mode 100644 index 00000000..66993273 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.latest.txt @@ -0,0 +1,27 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This variant of the requirements aims to test the system using +# the newest supported version of external dependencies. + +claude-agent-sdk>=0.1.0 +pytest==7.4.4 +pytest-asyncio==0.21.0 +wrapt==1.17.3 +opentelemetry-exporter-otlp-proto-http + +-e opentelemetry-instrumentation +-e instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk +-e util/opentelemetry-util-genai + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt new file mode 100644 index 00000000..93c3c2eb --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt @@ -0,0 +1,30 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This variant of the requirements aims to test the system using +# the oldest supported version of external dependencies. + +claude-agent-sdk>=0.1.0 +pytest==7.4.4 +pytest-asyncio==0.21.0 +wrapt==1.17.3 +opentelemetry-exporter-otlp-proto-http~=1.30 +opentelemetry-api==1.37 +opentelemetry-sdk==1.37 +opentelemetry-semantic-conventions==0.58b0 +opentelemetry-instrumentation==0.58b0 + +-e instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk +-e util/opentelemetry-util-genai + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_attributes.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_attributes.py new file mode 100644 index 00000000..af1d5e1b --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_attributes.py @@ -0,0 +1,322 @@ +"""Configuration and attribute tests for Claude Agent SDK instrumentation.""" + +import asyncio + +import pytest + +from opentelemetry.instrumentation import claude_agent_sdk +from opentelemetry.instrumentation.claude_agent_sdk import ( + ClaudeAgentSDKInstrumentor, + __version__, + hooks, + utils, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_span_attributes_semantic_conventions(instrument, span_exporter): + """Test that all spans follow semantic conventions.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt="Hello", options=options): + pass + + spans = span_exporter.get_finished_spans() + + for span in spans: + # All spans should have a name + assert span.name is not None + assert len(span.name) > 0 + + # Spans should have proper status + assert span.status is not None + + # Check if it's an LLM span + if GenAIAttributes.GEN_AI_OPERATION_NAME in span.attributes: + operation = span.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] + + if operation == "chat": + # LLM spans must have provider + assert GenAIAttributes.GEN_AI_PROVIDER_NAME in span.attributes + # LLM spans must have model + assert GenAIAttributes.GEN_AI_REQUEST_MODEL in span.attributes + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_agent_span_naming_convention(instrument, span_exporter): + """Test agent span naming follows conventions.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt="Test", options=options): + pass + + spans = span_exporter.get_finished_spans() + agent_spans = [s for s in spans if "invoke_agent" in s.name] + + assert len(agent_spans) >= 1 + agent_span = agent_spans[0] + + # Agent span name should contain agent name + assert ( + "claude-agent" in agent_span.name or "invoke_agent" in agent_span.name + ) + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_llm_span_naming_convention(instrument, span_exporter): + """Test LLM span naming follows conventions.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt="Test", options=options): + pass + + spans = span_exporter.get_finished_spans() + llm_spans = [ + s + for s in spans + if GenAIAttributes.GEN_AI_OPERATION_NAME in s.attributes + and s.attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" + ] + + assert len(llm_spans) >= 1 + llm_span = llm_spans[0] + + # LLM span name should follow pattern: "{operation} {model}" + assert "chat" in llm_span.name + assert "qwen" in llm_span.name.lower() or "qwen-plus" in llm_span.name + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_tool_span_naming_convention(instrument, span_exporter): + """Test tool span naming follows conventions.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + allowed_tools=["Write"], + max_turns=2, + ) + + async for _ in query( + prompt="Create a file test.txt with content 'test'", options=options + ): + pass + + spans = span_exporter.get_finished_spans() + tool_spans = [s for s in spans if "execute_tool" in s.name] + + if tool_spans: + tool_span = tool_spans[0] + # Tool span should have tool name in name + assert "execute_tool" in tool_span.name + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_span_context_propagation(instrument, span_exporter): + """Test that span context is properly propagated.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt="Test", options=options): + pass + + spans = span_exporter.get_finished_spans() + + # Find agent span + agent_spans = [s for s in spans if "invoke_agent" in s.name] + if not agent_spans: + return # No agent span, skip + + agent_span = agent_spans[0] + agent_span_id = agent_span.context.span_id + + # All other spans should have the agent span as parent + for span in spans: + if span != agent_span and span.parent: + # Parent should be agent span + assert span.parent.span_id == agent_span_id + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_token_usage_attributes(instrument, span_exporter): + """Test that token usage attributes are captured.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt="What is AI?", options=options): + pass + + spans = span_exporter.get_finished_spans() + llm_spans = [ + s + for s in spans + if GenAIAttributes.GEN_AI_OPERATION_NAME in s.attributes + ] + + if llm_spans: + llm_span = llm_spans[0] + + # Should have token usage (might not always be present) + # Just check the structure is correct if present + if GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS in llm_span.attributes: + input_tokens = llm_span.attributes[ + GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS + ] + assert isinstance(input_tokens, int) + assert input_tokens >= 0 + + if GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS in llm_span.attributes: + output_tokens = llm_span.attributes[ + GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS + ] + assert isinstance(output_tokens, int) + assert output_tokens >= 0 + + +def test_instrumentor_dependencies(instrument): + """Test that instrumentor declares dependencies correctly.""" + instrumentor = ClaudeAgentSDKInstrumentor() + deps = instrumentor.instrumentation_dependencies() + + # Should have claude-agent-sdk as dependency + assert len(deps) > 0 + assert any("claude-agent-sdk" in dep for dep in deps) + + +def test_instrumentor_with_custom_providers(tracer_provider, span_exporter): + """Test instrumentor with custom tracer and meter providers.""" + instrumentor = ClaudeAgentSDKInstrumentor() + meter_provider = MeterProvider() + + # Should accept custom providers + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + + instrumentor.uninstrument() + + +def test_version_exported(): + """Test that version is exported.""" + assert __version__ is not None + assert isinstance(__version__, str) + assert len(__version__) > 0 + + +def test_instrumentor_class_exported(): + """Test that ClaudeAgentSDKInstrumentor is exported.""" + assert hasattr(claude_agent_sdk, "ClaudeAgentSDKInstrumentor") + assert hasattr(claude_agent_sdk, "__version__") + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_multiple_concurrent_queries(instrument, span_exporter): + """Test that multiple concurrent queries are handled correctly.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async def run_query(prompt): + async for _ in query(prompt=prompt, options=options): + pass + + # Run multiple queries concurrently + await asyncio.gather( + run_query("What is 1+1?"), + run_query("What is 2+2?"), + ) + + spans = span_exporter.get_finished_spans() + + # Should have spans from both queries + # At least 2 agent spans + agent_spans = [s for s in spans if "invoke_agent" in s.name] + assert len(agent_spans) >= 2 + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_span_attributes_no_sensitive_data( + instrument_no_content, span_exporter +): + """Test that sensitive data is not captured when content capture is disabled.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + sensitive_prompt = "My password is secret123" + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + async for _ in query(prompt=sensitive_prompt, options=options): + pass + + spans = span_exporter.get_finished_spans() + + # Check that sensitive data is not in any span attributes + for span in spans: + for attr_value in span.attributes.values(): + if isinstance(attr_value, str): + # Sensitive content should not be in attributes + assert "secret123" not in attr_value.lower() + + +def test_hooks_are_exported(): + """Test that hooks are exported for external use.""" + # Check internal hooks can be imported + assert hasattr(hooks, "pre_tool_use_hook") + assert hasattr(hooks, "post_tool_use_hook") + + +def test_utils_are_internal(): + """Test that utils are properly organized.""" + # Utils should have the helper functions + assert hasattr(utils, "truncate_value") + assert hasattr(utils, "extract_usage_metadata") diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_edge_cases.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_edge_cases.py new file mode 100644 index 00000000..54befef2 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_edge_cases.py @@ -0,0 +1,353 @@ +"""Error handling and edge case tests for Claude Agent SDK instrumentation.""" + +import pytest + + +@pytest.mark.asyncio +async def test_query_with_api_error(instrument, span_exporter): + """Test that API errors are properly captured in spans.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + # Try a query that might fail (invalid prompt or rate limit) + try: + async for _ in query(prompt="", options=options): + pass + except Exception: + pass # Expected to fail + + # Get spans + spans = span_exporter.get_finished_spans() + + # Should still have spans even on error + assert len(spans) >= 0 + + +@pytest.mark.asyncio +async def test_query_with_empty_prompt(instrument, span_exporter): + """Test behavior with empty prompt.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + # Empty prompt should still be tracked + try: + count = 0 + async for _ in query(prompt="", options=options): + count += 1 + if count > 5: # Prevent infinite loop + break + except Exception: + pass + + +@pytest.mark.asyncio +async def test_client_context_manager_exception(instrument, span_exporter): + """Test that exceptions in context manager are handled.""" + from claude_agent_sdk import ClaudeSDKClient # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions(model="qwen-plus") + + try: + async with ClaudeSDKClient(options=options) as client: + await client.query(prompt="test") + # Simulate an error + raise RuntimeError("Simulated error") + except RuntimeError: + pass # Expected + + # Spans should still be exported + spans = span_exporter.get_finished_spans() + assert len(spans) >= 0 + + +def test_instrumentor_with_invalid_tracer_provider(): + """Test instrumentor with invalid tracer provider.""" + from opentelemetry.instrumentation.claude_agent_sdk import ( # noqa: PLC0415 + ClaudeAgentSDKInstrumentor, + ) + + instrumentor = ClaudeAgentSDKInstrumentor() + + # Should handle invalid provider gracefully + instrumentor.instrument(tracer_provider=None) + instrumentor.uninstrument() + + +def test_instrumentor_multiple_instrument_uninstrument_cycles(): + """Test multiple instrument/uninstrument cycles.""" + from opentelemetry.instrumentation.claude_agent_sdk import ( # noqa: PLC0415 + ClaudeAgentSDKInstrumentor, + ) + from opentelemetry.sdk.trace import TracerProvider # noqa: PLC0415 + + instrumentor = ClaudeAgentSDKInstrumentor() + tracer_provider = TracerProvider() + + # Multiple cycles should not cause issues + for _ in range(3): + instrumentor.instrument(tracer_provider=tracer_provider) + instrumentor.uninstrument() + + +def test_hook_with_none_tool_use_id(instrument): + """Test hook behavior with None tool_use_id.""" + import asyncio # noqa: PLC0415 + + from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( # noqa: PLC0415 + post_tool_use_hook, + pre_tool_use_hook, + ) + + # Pre hook with None ID + result = asyncio.run( + pre_tool_use_hook( + {"tool_name": "test", "tool_input": {}}, + None, # tool_use_id is None + {}, + ) + ) + assert result == {} + + # Post hook with None ID + result = asyncio.run( + post_tool_use_hook( + {"tool_name": "test", "tool_response": "ok"}, + None, # tool_use_id is None + {}, + ) + ) + assert result == {} + + +def test_hook_with_empty_input_data(instrument): + """Test hook behavior with empty input data.""" + import asyncio # noqa: PLC0415 + + from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( # noqa: PLC0415 + post_tool_use_hook, + pre_tool_use_hook, + ) + + # Pre hook with empty data + result = asyncio.run( + pre_tool_use_hook( + {}, # empty input + "test-id", + {}, + ) + ) + assert result == {} + + # Post hook with empty data + result = asyncio.run( + post_tool_use_hook( + {}, # empty input + "test-id", + {}, + ) + ) + assert result == {} + + +def test_context_clear_when_not_set(): + """Test clearing context when nothing is set.""" + from opentelemetry.instrumentation.claude_agent_sdk.context import ( # noqa: PLC0415 + clear_parent_invocation, + get_parent_invocation, + ) + + # Clear when empty + clear_parent_invocation() + + # Should return None + assert get_parent_invocation() is None + + # Clear again (should not raise) + clear_parent_invocation() + + +def test_context_set_with_none(): + """Test setting context with None value.""" + from opentelemetry.instrumentation.claude_agent_sdk.context import ( # noqa: PLC0415 + clear_parent_invocation, + get_parent_invocation, + set_parent_invocation, + ) + + # Set to None + set_parent_invocation(None) + + # Should retrieve None + assert get_parent_invocation() is None + + clear_parent_invocation() + + +def test_clear_active_tool_runs_with_empty_runs(): + """Test clearing tool runs when there are none.""" + from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( # noqa: PLC0415 + clear_active_tool_runs, + ) + + # Should not raise even if no active runs + clear_active_tool_runs() + + +def test_clear_active_tool_runs_multiple_times(): + """Test clearing tool runs multiple times.""" + from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( # noqa: PLC0415 + clear_active_tool_runs, + ) + + # Multiple clears should be safe + for _ in range(3): + clear_active_tool_runs() + + +def test_utils_truncate_with_zero_max_length(): + """Test truncate with zero max length.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + truncate_value, + ) + + result = truncate_value("hello", max_length=0) + # Should handle gracefully + assert isinstance(result, str) + + +def test_utils_truncate_with_negative_max_length(): + """Test truncate with negative max length.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + truncate_value, + ) + + result = truncate_value("hello", max_length=-1) + # Should handle gracefully + assert isinstance(result, str) + + +def test_utils_truncate_with_circular_reference(): + """Test truncate handles circular references.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + truncate_value, + ) + + # Create circular reference + a = {} + b = {"parent": a} + a["child"] = b + + # Should not infinite loop - will raise RecursionError which is expected + try: + result = truncate_value(a, max_length=100) + # If it doesn't raise, it should return a string + assert isinstance(result, str) + except RecursionError: + # This is acceptable - circular references are edge cases + pass + + +def test_utils_extract_usage_with_non_numeric_strings(): + """Test usage extraction with string values.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + extract_usage_metadata, + ) + + usage = { + "input_tokens": "100", + "output_tokens": "50", + } + + result = extract_usage_metadata(usage) + # Should attempt to convert strings to int + assert isinstance(result, dict) + + +def test_utils_sum_tokens_with_none_values(): + """Test token summation with None values.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + sum_anthropic_tokens, + ) + + usage = { + "input_tokens": None, + "output_tokens": None, + } + + result = sum_anthropic_tokens(usage) + # Should handle None values - converts to 0 + assert result["input_tokens"] == 0 + assert result["output_tokens"] == 0 + + +def test_utils_sum_tokens_with_negative_values(): + """Test token summation with negative values.""" + from opentelemetry.instrumentation.claude_agent_sdk.utils import ( # noqa: PLC0415 + sum_anthropic_tokens, + ) + + usage = { + "input_tokens": -10, + "output_tokens": 50, + } + + result = sum_anthropic_tokens(usage) + # Should process even if values are negative + assert result["input_tokens"] == -10 + assert result["output_tokens"] == 50 + + +@pytest.mark.asyncio +async def test_query_with_very_long_prompt(instrument, span_exporter): + """Test query with very long prompt.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + options = ClaudeAgentOptions( + model="qwen-plus", + max_turns=1, + ) + + # Very long prompt + long_prompt = "test " * 1000 + + try: + count = 0 + async for _ in query(prompt=long_prompt, options=options): + count += 1 + if count > 5: + break + except Exception: + pass # May fail due to token limits + + # Should still create spans + spans = span_exporter.get_finished_spans() + assert len(spans) >= 0 + + +def test_patch_with_missing_module(): + """Test that instrumentation handles missing SDK gracefully.""" + from opentelemetry.instrumentation.claude_agent_sdk import ( # noqa: PLC0415 + ClaudeAgentSDKInstrumentor, + ) + from opentelemetry.sdk.trace import TracerProvider # noqa: PLC0415 + + instrumentor = ClaudeAgentSDKInstrumentor() + + # Even if SDK is not installed properly, should not crash + try: + instrumentor.instrument(tracer_provider=TracerProvider()) + instrumentor.uninstrument() + except Exception: + pass # Expected if SDK is not installed diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_integration.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_integration.py new file mode 100644 index 00000000..c4fa4cb9 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_integration.py @@ -0,0 +1,183 @@ +"""Integration tests using mocked SDK client to avoid API calls. + +These tests mock the Claude Agent SDK at a lower level to simulate +realistic scenarios without requiring API keys. +""" + +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from opentelemetry.instrumentation.claude_agent_sdk import ( + ClaudeAgentSDKInstrumentor, +) +from opentelemetry.instrumentation.claude_agent_sdk.context import ( + clear_parent_invocation, + get_parent_invocation, + set_parent_invocation, +) +from opentelemetry.instrumentation.claude_agent_sdk.hooks import ( + post_tool_use_hook, + pre_tool_use_hook, +) +from opentelemetry.instrumentation.claude_agent_sdk.utils import ( + extract_usage_metadata, + sum_anthropic_tokens, + truncate_value, +) +from opentelemetry.sdk.metrics import MeterProvider + + +@pytest.mark.asyncio +async def test_client_with_mocked_response(instrument, span_exporter): + """Test client instrumentation with fully mocked SDK.""" + from claude_agent_sdk import ClaudeSDKClient # noqa: PLC0415 + from claude_agent_sdk.types import ClaudeAgentOptions # noqa: PLC0415 + + # Create a mock response + mock_msg = Mock() + mock_msg.content = [Mock(text="Mocked response", type="text")] + mock_msg.usage = Mock( + input_tokens=50, + output_tokens=10, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + ) + + options = ClaudeAgentOptions(model="qwen-plus") + + # Mock the underlying client query method + with patch.object( + ClaudeSDKClient, "query", new_callable=AsyncMock + ) as mock_query: + mock_query.return_value = [mock_msg] + + async with ClaudeSDKClient(options=options) as client: + result = await client.query(prompt="Test") + assert result is not None + + # Verify spans were created + spans = span_exporter.get_finished_spans() + # Note: spans might not be created if instrumentation doesn't hook into the mocked method + # This is expected behavior for this type of test + assert isinstance(spans, (list, tuple)) + + +@pytest.mark.asyncio +async def test_instrumentor_doesnt_crash_with_mocks(instrument, span_exporter): + """Test that instrumentor doesn't crash even with mock objects.""" + # This test验证instrumentation可以安全处理mock对象 + mock_msg = Mock() + mock_msg.content = [] + mock_msg.usage = None + + # 使用instrumented环境处理mock对象不应该崩溃 + try: + # Simulate what instrumentation might do + if hasattr(mock_msg, "usage") and mock_msg.usage: + pass # Would extract usage + if hasattr(mock_msg, "content"): + pass # Would process content + except Exception as e: + pytest.fail(f"Instrumentation crashed with mock object: {e}") + + # Should complete without error + assert True + + +def test_utils_work_with_mock_data(instrument): + """Test that utility functions work with mock data.""" + # Test with mock usage object + mock_usage = Mock() + mock_usage.input_tokens = 100 + mock_usage.output_tokens = 50 + + usage_data = extract_usage_metadata(mock_usage) + assert usage_data["input_tokens"] == 100 + assert usage_data["output_tokens"] == 50 + + # Test token summation + summed = sum_anthropic_tokens(usage_data) + assert summed["input_tokens"] == 100 + assert summed["output_tokens"] == 50 + + # Test truncation + truncated = truncate_value("test" * 100, max_length=50) + assert len(truncated) <= 53 # 50 + "..." + + +def test_context_operations_isolated(instrument): + """Test context operations work in isolated test environment.""" + # Set and retrieve + test_value = "test_invocation_123" + set_parent_invocation(test_value) + assert get_parent_invocation() == test_value + + # Clear + clear_parent_invocation() + assert get_parent_invocation() is None + + +def test_hooks_can_be_called_directly(instrument): + """Test that hooks can be called directly without crashing.""" + # Call pre hook + tool_data = { + "tool_name": "TestTool", + "tool_input": {"param": "value"}, + } + + try: + result = asyncio.run(pre_tool_use_hook(tool_data, "tool_123", {})) + assert isinstance(result, dict) + except Exception as e: + # Hook might need full context, but shouldn't crash hard + print(f"Hook raised: {e}") + + # Call post hook + result_data = { + "tool_name": "TestTool", + "tool_response": "success", + } + + try: + result = asyncio.run(post_tool_use_hook(result_data, "tool_123", {})) + assert isinstance(result, dict) + except Exception as e: + print(f"Hook raised: {e}") + + +def test_instrumentor_lifecycle_complete(tracer_provider): + """Test complete instrumentor lifecycle.""" + instrumentor = ClaudeAgentSDKInstrumentor() + + # Instrument + instrumentor.instrument(tracer_provider=tracer_provider) + assert instrumentor._handler is not None + + # Uninstrument + instrumentor.uninstrument() + assert instrumentor._handler is None + + # Re-instrument + instrumentor.instrument(tracer_provider=tracer_provider) + assert instrumentor._handler is not None + + # Final cleanup + instrumentor.uninstrument() + + +def test_instrumentation_with_different_configs(tracer_provider): + """Test instrumentation with different configurations.""" + instrumentor = ClaudeAgentSDKInstrumentor() + meter_provider = MeterProvider() + + # With both providers + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + ) + + assert instrumentor._handler is not None + + instrumentor.uninstrument() diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_mocks.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_mocks.py new file mode 100644 index 00000000..62f37f31 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_mocks.py @@ -0,0 +1,245 @@ +"""Mock-based tests for Claude Agent SDK instrumentation.""" + +import threading +from unittest.mock import Mock, patch + +import pytest + +from opentelemetry.instrumentation.claude_agent_sdk import ( + ClaudeAgentSDKInstrumentor, +) +from opentelemetry.instrumentation.claude_agent_sdk.context import ( + clear_parent_invocation, + get_parent_invocation, + set_parent_invocation, +) +from opentelemetry.instrumentation.claude_agent_sdk.utils import ( + extract_usage_from_result_message, + extract_usage_metadata, + sum_anthropic_tokens, + truncate_value, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +@pytest.mark.requires_cli +@pytest.mark.asyncio +async def test_agent_span_attributes_complete(instrument, span_exporter): + """Test that agent span has all required attributes.""" + from claude_agent_sdk import query # noqa: PLC0415 + from claude_agent_sdk.types import ( # noqa: PLC0415 + AssistantMessage, + ClaudeAgentOptions, + TextBlock, + ) + + # Mock the query function to return controlled data + with patch("claude_agent_sdk.query") as mock_query: + # Create mock messages + mock_assistant_msg = Mock(spec=AssistantMessage) + mock_assistant_msg.content = [Mock(spec=TextBlock, text="4")] + + async def mock_generator(*args, **kwargs): + yield mock_assistant_msg + + mock_query.return_value = mock_generator() + + # Execute with instrumentation + options = ClaudeAgentOptions(model="qwen-plus") + messages = [] + async for msg in query(prompt="2+2?", options=options): + messages.append(msg) + + # Get spans + spans = span_exporter.get_finished_spans() + assert len(spans) > 0 + + # Find agent span + agent_spans = [s for s in spans if "invoke_agent" in s.name] + if agent_spans: + agent_span = agent_spans[0] + + # Verify all semantic convention attributes + assert GenAIAttributes.GEN_AI_PROVIDER_NAME in agent_span.attributes + assert GenAIAttributes.GEN_AI_REQUEST_MODEL in agent_span.attributes + + +def test_utils_extract_usage_with_none(instrument): + """Test usage extraction with None input.""" + result = extract_usage_metadata(None) + assert result == {} + + +def test_utils_extract_usage_with_empty_dict(instrument): + """Test usage extraction with empty dict.""" + result = extract_usage_metadata({}) + assert result == {} + + +def test_utils_extract_usage_with_invalid_values(instrument): + """Test usage extraction with invalid values.""" + usage = { + "input_tokens": "invalid", + "output_tokens": None, + "cache_read_input_tokens": "not_a_number", + } + + result = extract_usage_metadata(usage) + # Should handle invalid values gracefully + assert isinstance(result, dict) + + +def test_utils_sum_tokens_with_missing_fields(instrument): + """Test token summation with missing fields.""" + # Missing output_tokens - should default to 0 + result = sum_anthropic_tokens({"input_tokens": 100}) + assert result["input_tokens"] == 100 + assert result["output_tokens"] == 0 + + # Missing input_tokens - should default to 0 + result = sum_anthropic_tokens({"output_tokens": 50}) + assert result["input_tokens"] == 0 + assert result["output_tokens"] == 50 + + +def test_utils_sum_tokens_with_cache_details(instrument): + """Test token summation with cache details in different formats.""" + # Note: Current implementation doesn't support nested input_token_details + # It only reads top-level cache_read_input_tokens and cache_creation_input_tokens + + # Format 1: nested input_token_details (NOT supported yet) + usage1 = { + "input_tokens": 100, + "output_tokens": 50, + "input_token_details": { + "cache_read": 10, + "cache_creation": 5, + }, + } + result1 = sum_anthropic_tokens(usage1) + # Since nested format is not supported, only gets base input_tokens + assert result1["input_tokens"] == 100 # No cache added + assert result1["output_tokens"] == 50 + + # Format 2: flat cache fields (supported) + usage2 = { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 10, + "cache_creation_input_tokens": 5, + } + result2 = sum_anthropic_tokens(usage2) + assert result2["input_tokens"] == 115 # 100 + 10 + 5 + assert result2["output_tokens"] == 50 + + +def test_utils_smart_truncate_edge_cases(instrument): + """Test smart truncate with various edge cases.""" + # Empty string + assert truncate_value("") == "" + + # None + assert truncate_value(None) == "None" + + # Numbers + assert truncate_value(42) == "42" + assert truncate_value(3.14) == "3.14" + + # Boolean + assert truncate_value(True) == "True" + + # Empty list + assert truncate_value([]) == "[]" + + # Empty dict + assert truncate_value({}) == "{}" + + # Nested structures + nested = {"a": {"b": {"c": [1, 2, 3]}}} + result = truncate_value(nested) + assert isinstance(result, str) + assert "{" in result + + +def test_context_thread_safety(instrument): + """Test context operations are thread-safe.""" + results = [] + + def thread_func(value): + set_parent_invocation(value) + retrieved = get_parent_invocation() + results.append(retrieved == value) + clear_parent_invocation() + + threads = [] + for i in range(5): + t = threading.Thread(target=thread_func, args=(f"invocation_{i}",)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + # Each thread should have retrieved its own value + assert all(results) + + +def test_instrumentor_double_instrument(instrument, tracer_provider): + """Test that double instrumentation doesn't cause issues.""" + # First instrumentation already done by fixture + # Try to instrument again + instrumentor2 = ClaudeAgentSDKInstrumentor() + instrumentor2.instrument(tracer_provider=tracer_provider) + + # Should not raise + instrumentor2.uninstrument() + + +def test_instrumentor_uninstrument_without_instrument(): + """Test uninstrument without prior instrument.""" + instrumentor = ClaudeAgentSDKInstrumentor() + # Should not raise even if not instrumented + instrumentor.uninstrument() + + +def test_usage_extraction_from_result_message_no_usage(instrument): + """Test usage extraction when result message has no usage.""" + # Mock message without usage + mock_msg = Mock() + mock_msg.usage = None + + result = extract_usage_from_result_message(mock_msg) + assert result == {} + + +def test_usage_extraction_from_result_message_with_usage(instrument): + """Test usage extraction with valid usage data.""" + # Mock message with usage + mock_msg = Mock() + mock_msg.usage = Mock() + mock_msg.usage.input_tokens = 100 + mock_msg.usage.output_tokens = 50 + mock_msg.usage.cache_read_input_tokens = 10 + mock_msg.usage.cache_creation_input_tokens = 5 + + result = extract_usage_from_result_message(mock_msg) + # Cache tokens should be summed into input_tokens + assert result["input_tokens"] == 115 # 100 + 10 + 5 + assert result["output_tokens"] == 50 + # Only standard OpenTelemetry fields + assert "total_tokens" not in result + assert "cache_read_input_tokens" not in result + + +def test_extract_usage_with_object_style_access(instrument): + """Test usage extraction with object attribute access.""" + # Mock object with attributes + mock_usage = Mock() + mock_usage.input_tokens = 100 + mock_usage.output_tokens = 50 + + result = extract_usage_metadata(mock_usage) + assert result["input_tokens"] == 100 + assert result["output_tokens"] == 50 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_unit.py b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_unit.py new file mode 100644 index 00000000..70cc5b27 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/test_unit.py @@ -0,0 +1,175 @@ +"""Unit tests for Claude Agent SDK instrumentation without VCR.""" + +import os + +from opentelemetry.instrumentation.claude_agent_sdk import ( + ClaudeAgentSDKInstrumentor, +) +from opentelemetry.instrumentation.claude_agent_sdk.context import ( + clear_parent_invocation, + get_parent_invocation, + set_parent_invocation, +) +from opentelemetry.instrumentation.claude_agent_sdk.utils import ( + extract_usage_metadata, + infer_provider_from_base_url, + sum_anthropic_tokens, + truncate_value, +) +from opentelemetry.sdk.trace import TracerProvider + + +def test_instrumentor_init(): + """Test that instrumentor can be initialized.""" + instrumentor = ClaudeAgentSDKInstrumentor() + assert instrumentor is not None + + +def test_instrument_and_uninstrument(): + """Test that instrumentation can be applied and removed.""" + tracer_provider = TracerProvider() + instrumentor = ClaudeAgentSDKInstrumentor() + + # Should not raise + instrumentor.instrument(tracer_provider=tracer_provider) + + # Should not raise + instrumentor.uninstrument() + + +def test_instrumentation_dependencies(): + """Test that instrumentation dependencies are defined.""" + instrumentor = ClaudeAgentSDKInstrumentor() + deps = instrumentor.instrumentation_dependencies() + + assert deps is not None + assert len(deps) > 0 + assert "claude-agent-sdk" in deps[0] + + +def test_utils_safe_truncate(): + """Test truncate_value utility function.""" + # Test short string + result = truncate_value("hello") + assert result == "hello" + + # Test long string + long_str = "a" * 200 + result = truncate_value(long_str, max_length=150) + assert len(result) <= 153 # 150 + "..." + assert result.endswith("...") + + # Test list + result = truncate_value([1, 2, 3]) + assert "[" in result + assert "]" in result + + # Test dict + result = truncate_value({"key": "value"}) + assert "{" in result + assert "}" in result + + +def test_context_operations(): + """Test thread-local context operations.""" + # Initially should be None + assert get_parent_invocation() is None + + # Set a mock invocation + mock_invocation = {"test": "value"} + set_parent_invocation(mock_invocation) + + # Should retrieve the same object + retrieved = get_parent_invocation() + assert retrieved == mock_invocation + + # Clear should remove it + clear_parent_invocation() + assert get_parent_invocation() is None + + +def test_usage_extraction(): + """Test usage metadata extraction.""" + # Test with dict + usage = { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 10, + "cache_creation_input_tokens": 5, + } + + result = extract_usage_metadata(usage) + assert result["input_tokens"] == 100 + assert result["output_tokens"] == 50 + # Cache tokens are temporarily extracted for summing + assert result["cache_read_input_tokens"] == 10 + assert result["cache_creation_input_tokens"] == 5 + + +def test_sum_anthropic_tokens(): + """Test Anthropic token summation.""" + usage = { + "input_tokens": 100, + "output_tokens": 50, + "cache_read_input_tokens": 10, + "cache_creation_input_tokens": 5, + } + + result = sum_anthropic_tokens(usage) + + # Should sum all input tokens + assert result["input_tokens"] == 115 # 100 + 10 + 5 + assert result["output_tokens"] == 50 + # Only standard OpenTelemetry fields in result + assert "cache_read_input_tokens" not in result + assert "cache_creation_input_tokens" not in result + assert "total_tokens" not in result + + +def test_infer_provider_from_base_url(): + """Test provider inference from ANTHROPIC_BASE_URL.""" + # Save original env var + original_url = os.environ.get("ANTHROPIC_BASE_URL") + + try: + # Test DashScope (extended provider) + os.environ["ANTHROPIC_BASE_URL"] = ( + "https://dashscope.aliyuncs.com/apps/anthropic" + ) + assert infer_provider_from_base_url() == "dashscope" + + # Test aliyuncs (alternative check for dashscope) + result = infer_provider_from_base_url("https://api.aliyuncs.com/v1") + assert result == "dashscope" + + # Test Moonshot (extended provider) + result = infer_provider_from_base_url("https://api.moonshot.cn/v1") + assert result == "moonshot" + + # Test Anthropic (defaults to anthropic) + os.environ["ANTHROPIC_BASE_URL"] = "https://api.anthropic.com" + assert infer_provider_from_base_url() == "anthropic" + + # Test ZhipuAI (defaults to anthropic) + os.environ["ANTHROPIC_BASE_URL"] = ( + "https://open.bigmodel.cn/api/anthropic" + ) + assert infer_provider_from_base_url() == "anthropic" + + # Test custom/unknown provider (defaults to anthropic) + result = infer_provider_from_base_url( + "https://api.unknown-provider.com" + ) + assert result == "anthropic" + + # Test empty (defaults to anthropic) + if "ANTHROPIC_BASE_URL" in os.environ: + del os.environ["ANTHROPIC_BASE_URL"] + assert infer_provider_from_base_url() == "anthropic" + + finally: + # Restore original env var + if original_url is not None: + os.environ["ANTHROPIC_BASE_URL"] = original_url + elif "ANTHROPIC_BASE_URL" in os.environ: + del os.environ["ANTHROPIC_BASE_URL"] diff --git a/tox-loongsuite.ini b/tox-loongsuite.ini index bafb6b6e..dca49b0c 100644 --- a/tox-loongsuite.ini +++ b/tox-loongsuite.ini @@ -20,6 +20,10 @@ envlist = py3{9,10,11,12,13}-test-loongsuite-instrumentation-dashscope-{oldest,latest} lint-loongsuite-instrumentation-dashscope + ; loongsuite-instrumentation-claude-agent-sdk + py3{10,11,12,13}-test-loongsuite-instrumentation-claude-agent-sdk-{oldest,latest} + lint-loongsuite-instrumentation-claude-agent-sdk + ; ; loongsuite-instrumentation-agno ; py3{9,10,11,12,13}-test-loongsuite-instrumentation-agno ; lint-loongsuite-instrumentation-agno @@ -61,6 +65,11 @@ deps = dashscope-latest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/requirements.latest.txt lint-loongsuite-instrumentation-dashscope: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/requirements.oldest.txt + claude-agent-sdk-oldest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt + claude-agent-sdk-latest: {[testenv]test_deps} + claude-agent-sdk-latest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.latest.txt + lint-loongsuite-instrumentation-claude-agent-sdk: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt + loongsuite-agno: {[testenv]test_deps} loongsuite-agno: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-agno/test-requirements.txt @@ -102,6 +111,9 @@ commands = test-loongsuite-instrumentation-dashscope: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests {posargs} lint-loongsuite-instrumentation-dashscope: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-dashscope + test-loongsuite-instrumentation-claude-agent-sdk: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk/tests {posargs} + lint-loongsuite-instrumentation-claude-agent-sdk: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-claude-agent-sdk + test-loongsuite-instrumentation-agno: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-agno/tests {posargs} lint-loongsuite-instrumentation-agno: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-agno diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_multimodal_upload/pre_uploader.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_multimodal_upload/pre_uploader.py index 9259db8c..7e403c39 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_multimodal_upload/pre_uploader.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_multimodal_upload/pre_uploader.py @@ -52,7 +52,7 @@ # Try importing audio processing libraries (optional dependencies) try: - import numpy as np + import numpy as np # pyright: ignore[reportMissingImports] import soundfile as sf # pyright: ignore[reportMissingImports] _audio_libs_available = True