diff --git a/.github/workflows/loongsuite_lint_0.yml b/.github/workflows/loongsuite_lint_0.yml index 970b36109..ec19cde2a 100644 --- a/.github/workflows/loongsuite_lint_0.yml +++ b/.github/workflows/loongsuite_lint_0.yml @@ -89,6 +89,25 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e lint-loongsuite-instrumentation-claude-agent-sdk + lint-loongsuite-instrumentation-google-adk: + name: LoongSuite loongsuite-instrumentation-google-adk + 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-google-adk + 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 17cfd4013..2ef853c67 100644 --- a/.github/workflows/loongsuite_test_0.yml +++ b/.github/workflows/loongsuite_test_0.yml @@ -526,6 +526,196 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-instrumentation-claude-agent-sdk-latest -- -ra + py39-test-loongsuite-instrumentation-google-adk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-oldest 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py39-test-loongsuite-instrumentation-google-adk-oldest -- -ra + + py39-test-loongsuite-instrumentation-google-adk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-latest 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py39-test-loongsuite-instrumentation-google-adk-latest -- -ra + + py310-test-loongsuite-instrumentation-google-adk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-oldest -- -ra + + py310-test-loongsuite-instrumentation-google-adk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-latest -- -ra + + py311-test-loongsuite-instrumentation-google-adk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-oldest -- -ra + + py311-test-loongsuite-instrumentation-google-adk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-latest -- -ra + + py312-test-loongsuite-instrumentation-google-adk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-oldest -- -ra + + py312-test-loongsuite-instrumentation-google-adk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-latest -- -ra + + py313-test-loongsuite-instrumentation-google-adk-oldest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-oldest -- -ra + + py313-test-loongsuite-instrumentation-google-adk-latest_ubuntu-latest: + name: LoongSuite loongsuite-instrumentation-google-adk-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-google-adk-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/CHANGELOG-loongsuite.md b/CHANGELOG-loongsuite.md index 445a71587..957030c4f 100644 --- a/CHANGELOG-loongsuite.md +++ b/CHANGELOG-loongsuite.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `loongsuite-instrumentation-google-adk`: Add initial support for Google Agent Development Kit (ADK) + ([#71](https://github.com/alibaba/loongsuite-python-agent/pull/71)) + - `loongsuite-instrumentation-agno`: fix aresponse missing await and double wrapped() calls in stream methods ([#107](https://github.com/alibaba/loongsuite-python-agent/pull/107)) diff --git a/instrumentation-loongsuite/README.md b/instrumentation-loongsuite/README.md index 03d08ed6b..8327b2d99 100644 --- a/instrumentation-loongsuite/README.md +++ b/instrumentation-loongsuite/README.md @@ -4,6 +4,7 @@ | [loongsuite-instrumentation-agentscope](./loongsuite-instrumentation-agentscope) | agentscope >= 0.1.5.dev0 | No | development | [loongsuite-instrumentation-agno](./loongsuite-instrumentation-agno) | agno >= 1.5.0 | No | development | [loongsuite-instrumentation-dify](./loongsuite-instrumentation-dify) | dify | No | development +| [loongsuite-instrumentation-google-adk](./loongsuite-instrumentation-google-adk) | google-adk >= 0.1.0 | Yes | experimental | [loongsuite-instrumentation-langchain](./loongsuite-instrumentation-langchain) | langchain_core >= 0.1.0 | Yes | development | [loongsuite-instrumentation-mcp](./loongsuite-instrumentation-mcp) | mcp>=1.3.0 | Yes | development | [loongsuite-instrumentation-mem0](./loongsuite-instrumentation-mem0) | mem0ai >= 1.0.0 | No | development \ No newline at end of file diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md new file mode 100644 index 000000000..3a2b2089b --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/README.md @@ -0,0 +1,331 @@ +# OpenTelemetry Google ADK Instrumentation + +[![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-GenAI_Semantic_Conventions-blue)](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/) + +Google ADK (Agent Development Kit) Python Agent provides comprehensive observability for Google ADK applications using OpenTelemetry. + +## Features + +- ✅ **Automatic Instrumentation**: Zero-code integration via `opentelemetry-instrument` +- ✅ **Manual Instrumentation**: Programmatic control via `GoogleAdkInstrumentor` +- ✅ **GenAI Semantic Conventions**: Full compliance with OpenTelemetry GenAI standards +- ✅ **Comprehensive Spans**: `invoke_agent`, `chat`, `execute_tool` +- ✅ **Standard Metrics**: Operation duration and token usage +- ✅ **Content Capture**: Optional message and response content capture +- ✅ **Google ADK native instrumentation Compatible**: Works seamlessly with ADK native instrumentation + +## Quick Start + +```bash +# Install +pip install google-adk litellm +pip install ./instrumentation-loongsuite/loongsuite-instrumentation-google-adk + +# Configure +export DASHSCOPE_API_KEY=your-api-key + +# Run with auto instrumentation +opentelemetry-instrument \ + --traces_exporter console \ + --service_name my-adk-app \ + python your_app.py +``` + +For details on LoongSuite and Jaeger setup, refer to [LoongSuite Documentation](https://github.com/alibaba/loongsuite-python-agent/blob/main/README.md). + +## Installing Google ADK Instrumentation + +```shell +# OpenTelemetry Core +pip install opentelemetry-distro opentelemetry-exporter-otlp +opentelemetry-bootstrap -a install + +# Google ADK and LLM Dependencies +pip install google-adk>=0.1.0 +pip install litellm + +# Demo Application Dependencies (optional, only if running examples) +pip install fastapi uvicorn pydantic + +# GoogleAdkInstrumentor +git clone https://github.com/alibaba/loongsuite-python-agent.git +cd loongsuite-python-agent +pip install ./instrumentation-loongsuite/loongsuite-instrumentation-google-adk +``` + +## Collect Data + +Here's a simple demonstration of Google ADK instrumentation. The demo uses: + +- A [Google ADK application](examples/main.py) that demonstrates agent interactions with multiple tools + +### Running the Demo + +> **Note**: The demo uses DashScope (Alibaba Cloud LLM service) by default. You need to set the `DASHSCOPE_API_KEY` environment variable. + +#### Option 1: Using OpenTelemetry Auto Instrumentation + +```bash +# Set your DashScope API key +export DASHSCOPE_API_KEY=your-dashscope-api-key + +# Enable content capture (optional, for debugging) +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +# Run with auto instrumentation +opentelemetry-instrument \ + --traces_exporter console \ + --service_name demo-google-adk \ + python examples/main.py +``` + +#### Option 2: Using Loongsuite + +```bash +# Set your DashScope API key +export DASHSCOPE_API_KEY=your-dashscope-api-key + +# Enable content capture (optional, for debugging) +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + +# Run with loongsuite instrumentation +loongsuite-instrument \ + --traces_exporter console \ + --service_name demo-google-adk \ + python examples/main.py +``` + +#### Option 3: Export to Jaeger + +```bash +# Set your DashScope API key +export DASHSCOPE_API_KEY=your-dashscope-api-key + +# Configure OTLP exporter +export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +export OTEL_TRACES_EXPORTER=otlp +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +# Run the application +opentelemetry-instrument \ + --service_name demo-google-adk \ + python examples/main.py +``` + +### Expected Results + +The instrumentation will generate traces showing the Google ADK operations: + +#### Tool Execution Span Example + +```json +{ + "name": "execute_tool get_current_time", + "context": { + "trace_id": "xxx", + "span_id": "xxx", + "trace_state": "[]" + }, + "kind": "SpanKind.INTERNAL", + "parent_id": "xxx", + "start_time": "2025-10-23T06:36:33.858459Z", + "end_time": "2025-10-23T06:36:33.858779Z", + "status": { + "status_code": "UNSET" + }, + "attributes": { + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": "get_current_time", + "gen_ai.tool.description": "xxx", + "input.value": "{xxx}", + "output.value": "{xxx}" + }, + "events": [], + "links": [], + "resource": { + "attributes": { + "telemetry.sdk.language": "python", + "telemetry.sdk.name": "opentelemetry", + "telemetry.sdk.version": "1.37.0", + "service.name": "demo-google-adk" + }, + "schema_url": "" + } +} +``` + +#### LLM Chat Span Example + +```json +{ + "name": "chat qwen-max", + "kind": "SpanKind.CLIENT", + "attributes": { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "qwen-max", + "gen_ai.response.model": "qwen-max", + "gen_ai.usage.input_tokens": 150, + "gen_ai.usage.output_tokens": 45 + } +} +``` + +#### Agent Invocation Span Example + +```json +{ + "name": "invoke_agent ToolAgent", + "kind": "SpanKind.CLIENT", + "attributes": { + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "ToolAgent", + "input.value": "[{\"role\": \"user\", \"parts\": [{\"type\": \"text\", \"content\": \"现在几点了?\"}]}]", + "output.value": "[{\"role\": \"assistant\", \"parts\": [{\"type\": \"text\", \"content\": \"当前时间是 2025-11-27 14:36:33\"}]}]" + } +} +``` + +### Viewing in Jaeger + +After [setting up Jaeger](https://www.jaegertracing.io/docs/latest/getting-started/), you can visualize the complete trace hierarchy in the Jaeger UI, showing the relationships between Runner, Agent, LLM, and Tool spans + +## Configuration + +### Environment Variables + +The following environment variables can be used to configure the Google ADK instrumentation: + +| Variable | Description | Default | +|----------|-------------|---------| +| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Capture message content in traces | `false` | +| `DASHSCOPE_API_KEY` | DashScope API key (required for demo) | - | + +### Programmatic Configuration + +You can also configure the instrumentation programmatically: + +```python +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor + +# Configure the instrumentor +instrumentor = GoogleAdkInstrumentor() + +# Enable instrumentation with custom configuration +instrumentor.instrument( + tracer_provider=your_tracer_provider, + meter_provider=your_meter_provider +) +``` + +## Supported Features + +### Traces + +The Google ADK instrumentation automatically creates traces for: + +- **Agent Runs**: Complete agent execution cycles +- **Tool Calls**: Individual tool invocations +- **Model Interactions**: LLM requests and responses +- **Session Management**: User session tracking +- **Error Handling**: Exception and error tracking + +### Metrics + +The instrumentation follows the [OpenTelemetry GenAI Semantic Conventions for Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md) and provides the following **standard client metrics**: + +#### 1. `gen_ai.client.operation.duration` (Histogram) + +Records the duration of GenAI operations in seconds. + +**Instrument Type**: Histogram +**Unit**: `s` (seconds) +**Status**: Development + +**Required Attributes**: +- `gen_ai.operation.name`: Operation being performed (e.g., `chat`, `invoke_agent`, `execute_tool`) +- `gen_ai.provider.name`: Provider name (e.g., `google_adk`) + +**Conditionally Required Attributes**: +- `error.type`: Error type (only if operation ended in error) +- `gen_ai.request.model`: Model name (if available) + +**Recommended Attributes**: +- `gen_ai.response.model`: Response model name +- `server.address`: Server address +- `server.port`: Server port + +**Example Values**: +- LLM operation: `gen_ai.operation.name="chat"`, `gen_ai.request.model="gemini-pro"`, `duration=1.5s` +- Agent operation: `gen_ai.operation.name="invoke_agent"`, `gen_ai.request.model="math_tutor"`, `duration=2.3s` +- Tool operation: `gen_ai.operation.name="execute_tool"`, `gen_ai.request.model="calculator"`, `duration=0.5s` + +#### 2. `gen_ai.client.token.usage` (Histogram) + +Records the number of tokens used in GenAI operations. + +**Instrument Type**: Histogram +**Unit**: `{token}` +**Status**: Development + +**Required Attributes**: +- `gen_ai.operation.name`: Operation being performed +- `gen_ai.provider.name`: Provider name +- `gen_ai.token.type`: Token type (`input` or `output`) + +**Conditionally Required Attributes**: +- `gen_ai.request.model`: Model name (if available) + +**Recommended Attributes**: +- `gen_ai.response.model`: Response model name +- `server.address`: Server address +- `server.port`: Server port + +**Example Values**: +- Input tokens: `gen_ai.token.type="input"`, `gen_ai.request.model="gemini-pro"`, `count=100` +- Output tokens: `gen_ai.token.type="output"`, `gen_ai.request.model="gemini-pro"`, `count=50` + +**Note**: These metrics use **Histogram** instrument type (not Counter) and follow the standard OpenTelemetry GenAI semantic conventions. All other metrics (like `genai.agent.runs.count`, etc.) are non-standard and have been removed to ensure compliance with the latest OTel specifications. + +### Semantic Conventions + +This instrumentation follows the OpenTelemetry GenAI semantic conventions: + +- [GenAI Spans](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md) +- [GenAI Agent Spans](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-agent-spans.md) +- [GenAI Metrics](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-metrics.md) + + + +## Troubleshooting + +### Common Issues + +1. **Module Import Error**: If you encounter `No module named 'google.adk.runners'`, ensure that `google-adk` is properly installed: + ```bash + pip install google-adk>=0.1.0 + ``` + +2. **DashScope API Error**: If you see authentication errors, verify your API key is correctly set: + ```bash + export DASHSCOPE_API_KEY=your-api-key + # Verify it's set + echo $DASHSCOPE_API_KEY + ``` + +3. **Instrumentation Not Working**: + - Check that the instrumentation is enabled and the Google ADK application is using the `Runner` class + - Verify you see the log message: `Plugin 'opentelemetry_adk_observability' registered` + - For manual instrumentation, ensure you call `GoogleAdkInstrumentor().instrument()` before creating the Runner + +4. **Missing Traces**: + - Verify that the OpenTelemetry exporters are properly configured + - Check the `OTEL_TRACES_EXPORTER` environment variable is set (e.g., `console`, `otlp`) + - For OTLP exporter, ensure the endpoint is reachable + + +## References + +- [OpenTelemetry Project](https://opentelemetry.io/) +- [Google ADK Documentation](https://google.github.io/adk-docs/) +- [GenAI Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py new file mode 100644 index 000000000..f0fc6ef41 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/main.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +工具使用示例 (HTTP 服务版本) +展示如何在 ADK Agent 中使用各种工具函数并部署为 HTTP 服务 +""" + +import asyncio +import logging +import os +import sys +from datetime import datetime +from typing import Any, Dict, Optional + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +# 设置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# 检查环境变量 +api_key = os.getenv("DASHSCOPE_API_KEY") +if not api_key: + print("❌ 请设置 DASHSCOPE_API_KEY 环境变量:") + print(" export DASHSCOPE_API_KEY='your-dashscope-api-key'") + print("🔗 获取地址: https://dashscope.console.aliyun.com/") + sys.exit(1) + +try: + # 导入 ADK 相关模块 + from google.adk.agents import LlmAgent + from google.adk.models.lite_llm import LiteLlm + from google.adk.runners import Runner + from google.adk.sessions.in_memory_session_service import ( + InMemorySessionService, + ) + from google.adk.tools import FunctionTool + from google.genai import types +except ImportError as e: + print(f"❌ 导入 ADK 模块失败: {e}") + print("📦 请确保已正确安装 Google ADK:") + print(" pip install google-adk") + sys.exit(1) + +# 导入自定义工具 +try: + from tools import ( + calculate_math, + check_prime_numbers, + get_current_time, + get_weather_info, + roll_dice, + search_web, + translate_text, + ) +except ImportError as e: + print(f"❌ 导入自定义工具失败: {e}") + sys.exit(1) + +# 配置阿里云百炼模型 +DASHSCOPE_CONFIG = { + "model": "dashscope/qwen-plus", + "api_key": api_key, + "temperature": 0.7, + "max_tokens": 1000, +} + +# 设置LiteLLM的环境变量 +os.environ["DASHSCOPE_API_KEY"] = api_key + +# ==================== 数据模型定义 ==================== + + +class ToolsRequest(BaseModel): + """工具使用请求模型""" + + task: str + session_id: Optional[str] = None + user_id: Optional[str] = "default_user" + + +class ApiResponse(BaseModel): + """API 响应模型""" + + success: bool + message: str + data: Optional[Dict[str, Any]] = None + timestamp: str + session_id: Optional[str] = None + + +def extract_content_text(content) -> str: + """ + 从 Content 对象中提取文本内容 + + Args: + content: Content 对象,包含 parts 列表 + + Returns: + 提取到的文本内容 + """ + if not content: + return "" + + # 如果 content 是字符串,直接返回 + if isinstance(content, str): + return content + + # 如果 content 有 parts 属性 + if hasattr(content, "parts") and content.parts: + text_parts = [] + for part in content.parts: + if hasattr(part, "text") and part.text: + text_parts.append(part.text) + return "".join(text_parts) + + # 如果 content 有 text 属性 + if hasattr(content, "text") and content.text: + return content.text + + # 如果都没有,返回空字符串 + return "" + + +async def create_agent() -> LlmAgent: + """创建带工具的 LLM Agent 实例""" + + # 创建 LiteLlm 模型实例 + dashscope_model = LiteLlm( + model=DASHSCOPE_CONFIG["model"], + api_key=DASHSCOPE_CONFIG["api_key"], + temperature=DASHSCOPE_CONFIG["temperature"], + max_tokens=DASHSCOPE_CONFIG["max_tokens"], + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + + # 创建工具 + time_tool = FunctionTool(func=get_current_time) + calc_tool = FunctionTool(func=calculate_math) + dice_tool = FunctionTool(func=roll_dice) + prime_tool = FunctionTool(func=check_prime_numbers) + weather_tool = FunctionTool(func=get_weather_info) + search_tool = FunctionTool(func=search_web) + translate_tool = FunctionTool(func=translate_text) + + # 创建 Agent + agent = LlmAgent( + name="tools_assistant", + model=dashscope_model, + instruction="""你是一个功能丰富的智能助手,可以使用多种工具来帮助用户。 + +你可以使用的工具包括: +1. 🕐 get_current_time - 获取当前时间 +2. 🧮 calculate_math - 进行数学计算 +3. 🎲 roll_dice - 掷骰子 +4. 🔢 check_prime_numbers - 检查质数 +5. 🌤️ get_weather_info - 获取天气信息 +6. 🔍 search_web - 搜索网络信息 +7. 🌍 translate_text - 翻译文本 + +使用原则: +- 用中文与用户交流 +- 对用户友好和专业 +- 当需要使用工具时,主动调用相应的工具函数 +- 基于工具返回的结果给出完整回答 +- 如果用户请求的功能没有对应工具,要诚实说明""", + description="一个可以使用多种工具的智能助手", + tools=[ + time_tool, + calc_tool, + dice_tool, + prime_tool, + weather_tool, + search_tool, + translate_tool, + ], + ) + + return agent + + +# ==================== 服务实现 ==================== + +# 全局变量存储服务组件 +session_service = None +runner = None +agent = None + + +async def initialize_services(): + """初始化服务组件""" + global session_service, runner, agent + + if session_service is None: + logger.info("🔧 初始化服务组件...") + session_service = InMemorySessionService() + agent = await create_agent() + runner = Runner( + app_name="tools_agent_demo", + agent=agent, + session_service=session_service, + ) + logger.info("✅ 服务组件初始化完成") + + +async def run_conversation( + user_input: str, user_id: str, session_id: str = "default_session" +) -> str: + """运行对话并返回回复""" + try: + # 初始化服务 + await initialize_services() + + # 直接创建新会话,不检查是否存在 + logger.info(f"创建新会话: {session_id}") + session = await session_service.create_session( + app_name="tools_agent_demo", user_id=user_id, session_id=session_id + ) + + logger.info(f"使用会话: {session.id}") + + # 创建用户消息 + user_message = types.Content( + role="user", parts=[types.Part(text=user_input)] + ) + + # 运行对话 + events = [] + async for event in runner.run_async( + user_id=user_id, session_id=session.id, new_message=user_message + ): + events.append(event) + + # 获取回复 + for event in events: + if hasattr(event, "content") and event.content: + # 提取 Content 对象中的文本 + content_text = extract_content_text(event.content) + if content_text: + logger.info(f"收到回复: {content_text[:100]}...") + return content_text + + logger.warning("未收到有效回复") + return "抱歉,我没有收到有效的回复。" + + except Exception as e: + logger.error(f"处理消息时出错: {e}") + import traceback # noqa: PLC0415 + + logger.error(f"详细错误信息: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"处理消息失败: {str(e)}") + + +# ==================== FastAPI 应用 ==================== + +# 创建 FastAPI 应用 +app = FastAPI( + title="ADK 工具使用 Agent HTTP 服务", + description="基于 Google ADK 框架的工具使用 Agent HTTP 服务", + version="1.0.0", +) + + +@app.on_event("startup") +async def startup_event(): + """应用启动时初始化服务""" + logger.info("🚀 启动工具使用 Agent HTTP 服务...") + await initialize_services() + logger.info("✅ 服务启动完成") + + +@app.get("/") +async def root(): + """服务状态检查""" + return ApiResponse( + success=True, + message="工具使用 Agent HTTP 服务运行正常", + data={ + "service": "ADK Tools Agent HTTP Service", + "version": "1.0.0", + "available_tools": [ + "get_current_time: 获取当前时间", + "calculate_math: 数学计算", + "roll_dice: 投骰子", + "check_prime_numbers: 质数检查", + "get_weather_info: 天气信息", + "search_web: 网络搜索", + "translate_text: 文本翻译", + ], + "capabilities": [ + "工具自动调用", + "多种实用功能", + "智能任务处理", + "结果整合分析", + ], + }, + timestamp=datetime.now().isoformat(), + ) + + +@app.post("/tools") +async def tools(request: ToolsRequest): + """工具使用任务处理接口""" + try: + session_id = ( + request.session_id + or f"tools_{request.user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + + response = await run_conversation( + user_input=request.task, + user_id=request.user_id or "default_user", + session_id=session_id, + ) + + return ApiResponse( + success=True, + message="工具任务处理成功", + data={"task": request.task, "response": response}, + timestamp=datetime.now().isoformat(), + session_id=session_id, + ) + + except Exception as e: + logger.error(f"工具任务处理错误: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """全局异常处理""" + logger.error(f"全局异常: {exc}") + return JSONResponse( + status_code=500, + content={ + "success": False, + "message": f"服务器内部错误: {str(exc)}", + "timestamp": datetime.now().isoformat(), + }, + ) + + +def main(): + """主函数 - 启动 HTTP 服务""" + print("🚀 ADK 工具使用 Agent HTTP 服务") + print("=" * 50) + print("🔑 API Key 已设置") + print("🔧 可用工具:") + print(" 1. get_current_time - 获取当前时间") + print(" 2. calculate_math - 数学计算") + print(" 3. roll_dice - 投骰子") + print(" 4. check_prime_numbers - 质数检查") + print(" 5. get_weather_info - 天气信息") + print(" 6. search_web - 网络搜索") + print(" 7. translate_text - 文本翻译") + print("=" * 50) + print("\n📡 HTTP 接口说明:") + print(" GET / - 服务状态检查") + print(" POST /tools - 工具使用任务处理接口") + print("\n💡 示例请求:") + print(" curl -X POST http://localhost:8000/tools \\") + print(" -H 'Content-Type: application/json' \\") + print(' -d \'{"task": "现在几点了?"}\'') + print("\n🌐 启动服务...") + + # 启动 FastAPI 服务 + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + log_level="info", + access_log=True, + ) + + +# 保留原有的命令行测试功能 +async def run_test_conversation(): + """运行测试对话""" + print("🚀 启动工具使用示例") + print("=" * 50) + print("🔑 API Key 已设置") + print(f"🤖 模型: {DASHSCOPE_CONFIG['model']}") + print("=" * 50) + + try: + # 初始化服务 + await initialize_services() + print("✅ Agent 初始化成功") + + # 示例对话 + test_inputs = [ + "现在几点了?", + "计算 123 乘以 456", + "掷一个六面骰子", + "检查 17, 25, 29, 33 是否为质数", + "北京的天气怎么样?", + "搜索人工智能的定义", + "翻译'你好'成英文", + ] + + session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + for i, user_input in enumerate(test_inputs, 1): + print(f"\n💬 测试 {i}: {user_input}") + print("-" * 30) + + response = await run_conversation( + user_input, "default_user", session_id + ) + print(f"🤖 回复: {response}") + + # 添加延迟避免请求过快 + await asyncio.sleep(1) + + print("\n✅ 所有测试已完成,程序结束") + + except Exception as e: + print(f"❌ 运行失败: {e}") + logger.exception("运行失败") + + +def run_test(): + """运行测试对话""" + asyncio.run(run_test_conversation()) + + +if __name__ == "__main__": + # 检查是否要运行测试模式 + if len(sys.argv) > 1 and sys.argv[1] == "test": + run_test() + else: + # 启动 HTTP 服务 + try: + main() + except KeyboardInterrupt: + print("\n👋 服务已停止") + except Exception as e: + print(f"❌ 服务启动失败: {e}") + logger.exception("Service startup failed") diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py new file mode 100644 index 000000000..9ce4d0271 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/examples/tools.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +工具函数定义 +包含各种类型的工具函数供 Agent 使用 +""" + +import math +import random +from datetime import datetime +from typing import Any, Dict, List + + +def get_current_time() -> str: + """ + 获取当前时间 + + Returns: + 当前时间的字符串表示 + """ + return f"当前时间是: {datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}" + + +def calculate_math(expression: str) -> str: + """ + 数学计算工具函数 + + Args: + expression: 数学表达式字符串 + + Returns: + 计算结果的字符串 + """ + try: + # 安全的数学表达式计算 + allowed_names = { + k: v for k, v in math.__dict__.items() if not k.startswith("__") + } + allowed_names.update( + {"abs": abs, "round": round, "pow": pow, "min": min, "max": max} + ) + + result = eval(expression, {"__builtins__": {}}, allowed_names) + return f"🔢 计算结果:{expression} = {result}" + except Exception as e: + return f"❌ 计算错误:{str(e)}" + + +def roll_dice(sides: int = 6) -> int: + """ + 掷骰子工具函数 + + Args: + sides: 骰子面数,默认为6 + + Returns: + 掷骰子的结果 + """ + if sides < 2: + sides = 6 + return random.randint(1, sides) + + +def check_prime_numbers(numbers: List[int]) -> Dict[str, Any]: + """ + 检查数字是否为质数 + + Args: + numbers: 要检查的数字列表 + + Returns: + 包含检查结果的字典 + """ + + def is_prime(n): + if n < 2: + return False + if n == 2: + return True + if n % 2 == 0: + return False + for i in range(3, int(math.sqrt(n)) + 1, 2): + if n % i == 0: + return False + return True + + results = {} + primes = [] + non_primes = [] + + for num in numbers: + if is_prime(num): + primes.append(num) + else: + non_primes.append(num) + results[str(num)] = is_prime(num) + + return { + "results": results, + "primes": primes, + "non_primes": non_primes, + "summary": f"在 {numbers} 中,质数有: {primes},非质数有: {non_primes}", + } + + +def get_weather_info(city: str) -> str: + """ + 获取天气信息工具函数(模拟) + + Args: + city: 城市名称 + + Returns: + 天气信息字符串 + """ + # 模拟天气数据 + weather_data = { + "北京": "晴朗,温度 15°C,湿度 45%,微风", + "上海": "多云,温度 18°C,湿度 60%,东南风", + "深圳": "小雨,温度 25°C,湿度 80%,南风", + "杭州": "阴天,温度 20°C,湿度 55%,西北风", + "广州": "晴朗,温度 28°C,湿度 65%,东风", + } + + weather = weather_data.get(city, f"{city}的天气信息暂时无法获取") + return f"📍 {city}的天气:{weather}" + + +def search_web(query: str) -> str: + """ + 网络搜索工具函数(模拟) + + Args: + query: 搜索查询 + + Returns: + 搜索结果字符串 + """ + # 模拟搜索结果 + mock_results = { + "人工智能": "人工智能是计算机科学的一个分支,它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。", + "机器学习": "机器学习是人工智能的一个分支,是一门多领域交叉学科,涉及概率论、统计学、逼近论、凸分析、算法复杂度理论等多门学科。", + "深度学习": "深度学习是机器学习的一个分支,它基于人工神经网络,利用多层非线性变换对数据进行特征提取和转换。", + "自然语言处理": "自然语言处理是计算机科学领域与人工智能领域中的一个重要方向,它研究能实现人与计算机之间用自然语言进行有效通信的各种理论和方法。", + } + + for key, value in mock_results.items(): + if key in query: + return value + + return f"🔍 关于'{query}'的搜索结果:这是模拟的搜索结果,实际应用中会连接真实的搜索引擎API。" + + +def translate_text(text: str, target_language: str = "en") -> str: + """ + 文本翻译工具函数(模拟) + + Args: + text: 要翻译的文本 + target_language: 目标语言代码 + + Returns: + 翻译结果字符串 + """ + # 模拟翻译结果 + translations = { + "你好": "Hello", + "谢谢": "Thank you", + "再见": "Goodbye", + "人工智能": "Artificial Intelligence", + "机器学习": "Machine Learning", + } + + if target_language.lower() == "en": + return translations.get(text, f"Translated: {text}") + else: + return f"翻译到{target_language}:{text}" + + +# 导出所有工具函数 +__all__ = [ + "get_current_time", + "calculate_math", + "roll_dice", + "check_prime_numbers", + "get_weather_info", + "search_web", + "translate_text", +] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml new file mode 100644 index 000000000..e4710a906 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/pyproject.toml @@ -0,0 +1,96 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "loongsuite-instrumentation-google-adk" +dynamic = ["version"] +description = "OpenTelemetry instrumentation for Google Agent Development Kit (ADK)" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.9" +authors = [ + { name = "LoongSuite Python Agent Authors" }, +] +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.9", + "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-sdk ~= 1.37", + "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-util-genai ~= 0.2b0", + "wrapt >= 1.0.0, < 2.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest >= 7.0.0", + "pytest-asyncio >= 0.21.0", + "pytest-cov >= 4.0.0", + "google-adk >= 0.1.0", +] +instruments = [ + "google-adk >= 0.1.0", +] + +[project.urls] +Homepage = "https://github.com/alibaba/loongsuite-python-agent/tree/main/instrumentation-loongsuite/loongsuite-instrumentation-google-adk" +Repository = "https://github.com/alibaba/loongsuite-python-agent" + +[project.entry-points.opentelemetry_instrumentor] +google-adk = "opentelemetry.instrumentation.google_adk:GoogleAdkInstrumentor" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/google_adk/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "src", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=src/opentelemetry/instrumentation/google_adk --cov-report=term-missing --cov-report=html" +filterwarnings = [ + # Filter Google ADK SDK deprecation warnings + "ignore:The `plugins` argument is deprecated:DeprecationWarning:google.adk.runners", + "ignore:deprecated:DeprecationWarning:google.adk.runners", + # Filter OpenTelemetry attribute type warnings (should be fixed in code, but filter for now) + "ignore:Invalid type.*in attribute.*finish_reasons:UserWarning:opentelemetry.attributes", +] + +[tool.coverage.run] +source = ["src"] +branch = true +omit = [ + "*/tests/*", + "*/test_*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "@abstractmethod", +] + + diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py new file mode 100644 index 000000000..4d950a586 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/__init__.py @@ -0,0 +1,3 @@ +"""OpenTelemetry namespace package.""" + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py new file mode 100644 index 000000000..6dfadabbe --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/__init__.py @@ -0,0 +1,3 @@ +"""OpenTelemetry instrumentation namespace package.""" + +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py new file mode 100644 index 000000000..91c3cf156 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/__init__.py @@ -0,0 +1,223 @@ +""" +OpenTelemetry Instrumentation for Google ADK. + +This package provides OpenTelemetry instrumentation for Google Agent Development Kit (ADK) +applications, following the OpenTelemetry GenAI semantic conventions. + +This implementation uses ExtendedTelemetryHandler from opentelemetry-util-genai +for standard span and metrics management. + +Usage: + # Manual instrumentation + from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor + GoogleAdkInstrumentor().instrument() + + # Auto instrumentation (via opentelemetry-instrument) + # opentelemetry-instrument python your_app.py +""" + +import logging +from typing import Collection, Optional + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.extended_handler import ExtendedTelemetryHandler + +from .internal._plugin import GoogleAdkObservabilityPlugin +from .version import __version__ as __version__ # noqa: F401 + +_logger = logging.getLogger(__name__) + +# Module-level storage for the plugin instance +# This ensures the plugin persists across different instrumentor instances +# and supports both manual and auto instrumentation modes +_global_plugin: Optional[GoogleAdkObservabilityPlugin] = None +_global_handler: Optional[ExtendedTelemetryHandler] = None + + +def _create_plugin_if_needed( + tracer_provider=None, meter_provider=None, logger_provider=None +): + """ + Create or get the global plugin instance. + + This function ensures that only one plugin instance exists for the + entire process, which is necessary for auto instrumentation to work + correctly when the instrumentor may be instantiated multiple times. + + Args: + tracer_provider: Optional tracer provider + meter_provider: Optional meter provider + logger_provider: Optional logger provider + + Returns: + GoogleAdkObservabilityPlugin instance + """ + global _global_plugin, _global_handler + + if _global_plugin is None: + # Create ExtendedTelemetryHandler with provided providers + _global_handler = ExtendedTelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + + # Create plugin with handler + _global_plugin = GoogleAdkObservabilityPlugin(_global_handler) + _logger.debug("Created global GoogleAdkObservabilityPlugin instance") + + return _global_plugin + + +def _runner_init_wrapper(wrapped, instance, args, kwargs): + """ + Wrapper for Runner.__init__ to auto-inject the observability plugin. + + This is a module-level function (not a method) to avoid issues with + instance state in auto instrumentation scenarios where the instrumentor + may be instantiated multiple times. + + Args: + wrapped: Original wrapped function + instance: Runner instance + args: Positional arguments + kwargs: Keyword arguments + + Returns: + Result of the original function + """ + # Get or create the plugin + plugin = _create_plugin_if_needed() + + if plugin: + # Get or create plugins list + plugins = kwargs.get("plugins", []) + if not isinstance(plugins, list): + plugins = [plugins] if plugins else [] + + # Add our plugin if not already present + if plugin not in plugins: + plugins.append(plugin) + kwargs["plugins"] = plugins + _logger.debug( + "Injected OpenTelemetry observability plugin into Runner" + ) + + # Call the original __init__ + return wrapped(*args, **kwargs) + + +class GoogleAdkInstrumentor(BaseInstrumentor): + """ + OpenTelemetry instrumentor for Google ADK. + + This instrumentor automatically injects observability into Google ADK applications + following OpenTelemetry GenAI semantic conventions. + + Uses ExtendedTelemetryHandler from opentelemetry-util-genai for: + - Automatic span lifecycle management + - Automatic metrics recording + - Standard GenAI semantic conventions + + Supports both manual and auto instrumentation modes: + - Manual: GoogleAdkInstrumentor().instrument() + - Auto: opentelemetry-instrument python your_app.py + """ + + def __init__(self): + """Initialize the instrumentor.""" + super().__init__() + + @property + def _plugin(self): + """ + Get the global plugin instance. + + This property provides backward compatibility with code that accesses + self.instrumentor._plugin (e.g., in tests). + + Returns: + The global plugin instance + """ + return _global_plugin + + def instrumentation_dependencies(self) -> Collection[str]: + """ + Return the list of instrumentation dependencies. + + Returns: + Collection of required packages + """ + return ["google-adk >= 0.1.0"] + + def _instrument(self, **kwargs): + """ + Instrument the Google ADK library. + + This method works in both manual and auto instrumentation modes by + using a module-level global plugin instance that persists across + multiple instrumentor instantiations. + + Args: + **kwargs: Optional keyword arguments: + - tracer_provider: Custom tracer provider + - meter_provider: Custom meter provider + - logger_provider: Custom logger provider + """ + # Check if google-adk is installed + import importlib.util # noqa: PLC0415 + + if importlib.util.find_spec("google.adk.runners") is None: + _logger.warning( + "google-adk not found, instrumentation will not be applied" + ) + return + + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + logger_provider = kwargs.get("logger_provider") + + # Create or get the global plugin instance + _create_plugin_if_needed( + tracer_provider, meter_provider, logger_provider + ) + + # Wrap the Runner initialization to auto-inject our plugin + try: + wrap_function_wrapper( + "google.adk.runners", + "Runner.__init__", + _runner_init_wrapper, # Use module-level function + ) + _logger.info("Google ADK instrumentation enabled") + except Exception as e: + _logger.exception(f"Failed to instrument Google ADK: {e}") + + def _uninstrument(self, **kwargs): + """ + Uninstrument the Google ADK library. + + Args: + **kwargs: Optional keyword arguments + """ + global _global_plugin, _global_handler + + try: + # Unwrap the Runner initialization + from google.adk.runners import Runner # noqa: PLC0415 + + unwrap(Runner, "__init__") + + # Clear the global plugin and handler + _global_plugin = None + _global_handler = None + + _logger.info("Google ADK instrumentation disabled") + except Exception as e: + _logger.exception(f"Failed to uninstrument Google ADK: {e}") + + +__all__ = ["GoogleAdkInstrumentor"] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py new file mode 100644 index 000000000..1d78902e4 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/__init__.py @@ -0,0 +1 @@ +"""Internal implementation modules for Google ADK instrumentation.""" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py new file mode 100644 index 000000000..2acd2a2ce --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_extractors.py @@ -0,0 +1,53 @@ +""" +ADK Attribute Extractors following OpenTelemetry GenAI Semantic Conventions. + +This module extracts trace attributes from Google ADK objects according +to OpenTelemetry GenAI semantic conventions (latest version). +""" + + +class AdkAttributeExtractors: + """ + Attribute extractors for Google ADK following OpenTelemetry GenAI semantic conventions. + + Extracts trace attributes from ADK objects according to: + - gen_ai.* attributes for GenAI-specific information + - Standard OpenTelemetry attributes for general information + """ + + def _extract_provider_name(self, model_name: str) -> str: + """ + Extract provider name from model name according to OTel GenAI conventions. + + Args: + model_name: Model name string + + Returns: + Provider name following OTel GenAI standard values + """ + if not model_name: + return "google_adk" + + model_lower = model_name.lower() + + # Google models - use standard values from OTel spec + if "gemini" in model_lower: + return "gcp.gemini" # AI Studio API + elif "vertex" in model_lower: + return "gcp.vertex_ai" # Vertex AI + # OpenAI models + elif "gpt" in model_lower or "openai" in model_lower: + return "openai" + # Anthropic models + elif "claude" in model_lower: + return "anthropic" + # Other providers + elif "llama" in model_lower or "meta" in model_lower: + return "meta" + elif "mistral" in model_lower: + return "mistral_ai" + elif "cohere" in model_lower: + return "cohere" + else: + # Default to google_adk for unknown models + return "google_adk" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py new file mode 100644 index 000000000..21b7af980 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/internal/_plugin.py @@ -0,0 +1,768 @@ +""" +OpenTelemetry ADK Observability Plugin. + +This module implements the core observability plugin using Google ADK's +plugin mechanism with OpenTelemetry GenAI semantic conventions. + +This implementation uses ExtendedTelemetryHandler from opentelemetry-util-genai +for standard span and metrics management. +""" + +import logging +from typing import Any, Dict, List, Optional + +from google.adk.agents.base_agent import BaseAgent +from google.adk.agents.callback_context import CallbackContext +from google.adk.agents.invocation_context import InvocationContext +from google.adk.events.event import Event +from google.adk.models.llm_request import LlmRequest +from google.adk.models.llm_response import LlmResponse +from google.adk.plugins.base_plugin import BasePlugin +from google.adk.tools.base_tool import BaseTool +from google.adk.tools.tool_context import ToolContext +from google.genai import types + +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, +) + +from ._extractors import AdkAttributeExtractors + +_logger = logging.getLogger(__name__) + + +class GoogleAdkObservabilityPlugin(BasePlugin): + """ + OpenTelemetry ADK Observability Plugin. + + Implements comprehensive observability for Google ADK applications + following OpenTelemetry GenAI semantic conventions. + + Uses ExtendedTelemetryHandler for standard span lifecycle management + and automatic metrics recording. + """ + + def __init__(self, handler: ExtendedTelemetryHandler): + """ + Initialize the observability plugin. + + Args: + handler: ExtendedTelemetryHandler instance for span/metrics management + """ + super().__init__(name="opentelemetry_adk_observability") + self._handler = handler + self._extractors = AdkAttributeExtractors() + + # Track active invocations for proper callback matching + self._active_runner_invocations: Dict[str, InvokeAgentInvocation] = {} + self._active_agent_invocations: Dict[str, InvokeAgentInvocation] = {} + self._active_llm_invocations: Dict[str, LLMInvocation] = {} + self._active_tool_invocations: Dict[str, ExecuteToolInvocation] = {} + + # Track user messages and final responses for Runner spans + self._runner_inputs: Dict[str, types.Content] = {} + self._runner_outputs: Dict[str, str] = {} + + # Track llm_request -> model mapping to avoid fallback model names + self._llm_req_models: Dict[str, str] = {} + + # ===== Runner Level Callbacks - Top-level invoke_agent span ===== + + async def before_run_callback( + self, *, invocation_context: InvocationContext + ) -> Optional[Any]: + """ + Start Runner execution - create top-level invoke_agent span. + + According to OTel GenAI conventions, Runner is treated as a top-level agent. + """ + try: + # Extract conversation_id + conversation_id = None + + if invocation_context.session: + conversation_id = invocation_context.session.id + + # Create invocation object + invocation = InvokeAgentInvocation( + provider="google_adk", + agent_name=invocation_context.app_name, + ) + + # Set conversation_id if available + if conversation_id: + invocation.conversation_id = conversation_id + + # Set custom attributes + if hasattr(invocation_context, "app_name"): + invocation.attributes["google_adk.runner.app_name"] = ( + invocation_context.app_name + ) + + if hasattr(invocation_context, "invocation_id"): + invocation.attributes["google_adk.runner.invocation_id"] = ( + invocation_context.invocation_id + ) + + # Check if we already have a stored user message + runner_key = f"runner_{invocation_context.invocation_id}" + if runner_key in self._runner_inputs: + user_message = self._runner_inputs[runner_key] + input_messages = self._convert_user_message_to_input_messages( + user_message + ) + invocation.input_messages = input_messages + + # Start invocation (creates span) + self._handler.start_invoke_agent(invocation) + + # Store invocation for later use + self._active_runner_invocations[runner_key] = invocation + + _logger.debug( + f"Started Runner invocation: invoke_agent {invocation_context.app_name}" + ) + + except Exception as e: + _logger.exception(f"Error in before_run_callback: {e}") + + return None + + async def on_user_message_callback( + self, + *, + invocation_context: InvocationContext, + user_message: types.Content, + ) -> Optional[types.Content]: + """ + Capture user input for Runner span. + + This callback is triggered when a user message is received. + """ + try: + # Store user message for later use in Runner span + runner_key = f"runner_{invocation_context.invocation_id}" + self._runner_inputs[runner_key] = user_message + + # Update active Runner invocation if it exists + invocation = self._active_runner_invocations.get(runner_key) + if invocation: + input_messages = self._convert_user_message_to_input_messages( + user_message + ) + invocation.input_messages = input_messages + + _logger.debug( + f"Captured user message for Runner: {invocation_context.invocation_id}" + ) + + except Exception as e: + _logger.exception(f"Error in on_user_message_callback: {e}") + + return None # Don't modify the user message + + async def on_event_callback( + self, *, invocation_context: InvocationContext, event: Event + ) -> Optional[Event]: + """ + Capture output events for Runner span. + + This callback is triggered for each event generated during execution. + """ + try: + # Extract text content from event if available + event_content = "" + if hasattr(event, "content") and event.content: + event_content = self._extract_text_from_content(event.content) + elif hasattr(event, "data") and event.data: + event_content = self._extract_text_from_content(event.data) + + if event_content: + runner_key = f"runner_{invocation_context.invocation_id}" + + # Accumulate output content + if runner_key not in self._runner_outputs: + self._runner_outputs[runner_key] = "" + self._runner_outputs[runner_key] += event_content + + # Update active Runner invocation + invocation = self._active_runner_invocations.get(runner_key) + if invocation: + output_messages = [ + OutputMessage( + role="assistant", + parts=[ + Text(content=self._runner_outputs[runner_key]) + ], + finish_reason="stop", + ) + ] + invocation.output_messages = output_messages + + _logger.debug( + f"Captured event for Runner: {invocation_context.invocation_id}" + ) + + except Exception as e: + _logger.exception(f"Error in on_event_callback: {e}") + + return None # Don't modify the event + + async def after_run_callback( + self, *, invocation_context: InvocationContext + ) -> Optional[None]: + """ + End Runner execution - finish top-level invoke_agent span. + """ + try: + runner_key = f"runner_{invocation_context.invocation_id}" + invocation = self._active_runner_invocations.pop(runner_key, None) + + if invocation: + # Stop invocation (ends span and records metrics automatically) + self._handler.stop_invoke_agent(invocation) + _logger.debug( + f"Finished Runner invocation for {invocation_context.app_name}" + ) + + # Clean up stored data + self._runner_inputs.pop(runner_key, None) + self._runner_outputs.pop(runner_key, None) + + except Exception as e: + _logger.exception(f"Error in after_run_callback: {e}") + + # ===== Agent Level Callbacks - invoke_agent span ===== + + async def before_agent_callback( + self, *, agent: BaseAgent, callback_context: CallbackContext + ) -> None: + """ + Start Agent execution - create invoke_agent span. + """ + try: + # Extract conversation_id + conversation_id = None + + if callback_context._invocation_context.session: + conversation_id = ( + callback_context._invocation_context.session.id + ) + + # Create invocation object + invocation = InvokeAgentInvocation( + provider="google_adk", + agent_name=agent.name, + ) + + # Set agent attributes + if hasattr(agent, "id") and agent.id: + invocation.agent_id = agent.id + + if hasattr(agent, "description") and agent.description: + invocation.agent_description = agent.description + + if conversation_id: + invocation.conversation_id = conversation_id + + # Start invocation (creates span) + self._handler.start_invoke_agent(invocation) + + # Store invocation for later use + agent_key = f"agent_{id(agent)}_{conversation_id}" + self._active_agent_invocations[agent_key] = invocation + + _logger.debug( + f"Started Agent invocation: invoke_agent {agent.name}" + ) + + except Exception as e: + _logger.exception(f"Error in before_agent_callback: {e}") + + async def after_agent_callback( + self, *, agent: BaseAgent, callback_context: CallbackContext + ) -> None: + """ + End Agent execution - finish invoke_agent span. + """ + try: + conversation_id = None + if callback_context._invocation_context.session: + conversation_id = ( + callback_context._invocation_context.session.id + ) + + agent_key = f"agent_{id(agent)}_{conversation_id}" + invocation = self._active_agent_invocations.pop(agent_key, None) + + if invocation: + # Stop invocation (ends span and records metrics automatically) + self._handler.stop_invoke_agent(invocation) + _logger.debug(f"Finished Agent invocation for {agent.name}") + + except Exception as e: + _logger.exception(f"Error in after_agent_callback: {e}") + + # ===== LLM Level Callbacks - chat span ===== + + async def before_model_callback( + self, *, callback_context: CallbackContext, llm_request: LlmRequest + ) -> None: + """ + Start LLM call - create chat span. + """ + try: + # Extract model name + model_name = llm_request.model if llm_request else "unknown" + + # Create invocation object + invocation = LLMInvocation( + request_model=model_name, + provider=self._extractors._extract_provider_name(model_name), + ) + + # Extract input messages + if llm_request.contents: + input_messages = self._convert_contents_to_input_messages( + llm_request.contents + ) + invocation.input_messages = input_messages + + # Extract request parameters + if llm_request.config: + config = llm_request.config + if hasattr(config, "max_tokens") and config.max_tokens: + invocation.max_tokens = config.max_tokens + if ( + hasattr(config, "temperature") + and config.temperature is not None + ): + invocation.temperature = config.temperature + if hasattr(config, "top_p") and config.top_p is not None: + invocation.top_p = config.top_p + + # Extract conversation_id and user_id + if callback_context._invocation_context.session: + invocation.attributes["gen_ai.conversation.id"] = ( + callback_context._invocation_context.session.id + ) + + user_id = getattr(callback_context, "user_id", None) + if not user_id: + user_id = getattr( + callback_context._invocation_context, "user_id", None + ) + if user_id: + invocation.attributes["enduser.id"] = user_id + + # Start invocation (creates span) + self._handler.start_llm(invocation) + + # Store invocation for later use + session_id = callback_context._invocation_context.session.id + request_key = f"llm_{id(llm_request)}_{session_id}" + self._active_llm_invocations[request_key] = invocation + + # Store the requested model for reliable retrieval later + if hasattr(llm_request, "model") and llm_request.model: + self._llm_req_models[request_key] = llm_request.model + + _logger.debug(f"Started LLM invocation: chat {model_name}") + + except Exception as e: + _logger.exception(f"Error in before_model_callback: {e}") + + async def after_model_callback( + self, *, callback_context: CallbackContext, llm_response: LlmResponse + ) -> None: + """ + End LLM call - finish chat span. + """ + try: + # Find the matching invocation + session_id = callback_context._invocation_context.session.id + llm_invocation = None + request_key = None + + for key, invocation in list(self._active_llm_invocations.items()): + if key.startswith("llm_") and session_id in key: + llm_invocation = self._active_llm_invocations.pop(key) + request_key = key + break + + if llm_invocation: + # Update invocation with response data + if llm_response: + # Set response model + if hasattr(llm_response, "model") and llm_response.model: + llm_invocation.response_model_name = llm_response.model + + # Extract token usage + if llm_response.usage_metadata: + usage = llm_response.usage_metadata + if hasattr(usage, "prompt_token_count"): + llm_invocation.input_tokens = ( + usage.prompt_token_count + ) + if hasattr(usage, "candidates_token_count"): + llm_invocation.output_tokens = ( + usage.candidates_token_count + ) + + # Extract finish reason + if hasattr(llm_response, "finish_reason"): + finish_reason = llm_response.finish_reason or "stop" + if hasattr(finish_reason, "value"): + finish_reason = finish_reason.value + elif not isinstance( + finish_reason, (str, int, float, bool) + ): + finish_reason = str(finish_reason) + llm_invocation.finish_reasons = [finish_reason] + + # Extract output messages + output_messages = ( + self._convert_llm_response_to_output_messages( + llm_response + ) + ) + llm_invocation.output_messages = output_messages + + # Stop invocation (ends span and records metrics automatically) + self._handler.stop_llm(llm_invocation) + + model_name = self._resolve_model_name( + llm_response, request_key, llm_invocation + ) + _logger.debug( + f"Finished LLM invocation for model {model_name}" + ) + + except Exception as e: + _logger.exception(f"Error in after_model_callback: {e}") + + async def on_model_error_callback( + self, + *, + callback_context: CallbackContext, + llm_request: LlmRequest, + error: Exception, + ) -> Optional[LlmResponse]: + """ + Handle LLM call errors. + """ + try: + # Find and finish the invocation with error status + session_id = callback_context._invocation_context.session.id + for key, invocation in list(self._active_llm_invocations.items()): + if key.startswith("llm_") and session_id in key: + invocation = self._active_llm_invocations.pop(key) + + # Fail invocation (sets error attributes and ends span) + self._handler.fail_llm( + invocation, Error(message=str(error), type=type(error)) + ) + break + + _logger.debug(f"Handled LLM error: {error}") + + except Exception as e: + _logger.exception(f"Error in on_model_error_callback: {e}") + + return None + + # ===== Tool Level Callbacks - execute_tool span ===== + + async def before_tool_callback( + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + ) -> None: + """ + Start Tool execution - create execute_tool span. + """ + try: + # Create invocation object + invocation = ExecuteToolInvocation( + tool_name=tool.name, + provider="google_adk", + ) + + # Set tool attributes + if hasattr(tool, "description") and tool.description: + invocation.tool_description = tool.description + + invocation.tool_type = "function" + + if hasattr(tool_context, "call_id") and tool_context.call_id: + invocation.tool_call_id = tool_context.call_id + + # Set tool arguments (content capture is controlled by the util layer) + if tool_args: + invocation.tool_call_arguments = tool_args + + # Start invocation (creates span) + self._handler.start_execute_tool(invocation) + + # Store invocation for later use + tool_key = f"tool_{id(tool)}_{id(tool_args)}" + self._active_tool_invocations[tool_key] = invocation + + _logger.debug(f"Started Tool invocation: execute_tool {tool.name}") + + except Exception as e: + _logger.exception(f"Error in before_tool_callback: {e}") + + async def after_tool_callback( + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + result: dict, + ) -> None: + """ + End Tool execution - finish execute_tool span. + """ + try: + tool_key = f"tool_{id(tool)}_{id(tool_args)}" + invocation = self._active_tool_invocations.pop(tool_key, None) + + if invocation: + # Set tool result (content capture is controlled by the util layer) + if result: + invocation.tool_call_result = result + + # Stop invocation (ends span and records metrics automatically) + self._handler.stop_execute_tool(invocation) + _logger.debug(f"Finished Tool invocation for {tool.name}") + + except Exception as e: + _logger.exception(f"Error in after_tool_callback: {e}") + + async def on_tool_error_callback( + self, + *, + tool: BaseTool, + tool_args: dict[str, Any], + tool_context: ToolContext, + error: Exception, + ) -> Optional[dict]: + """ + Handle Tool execution errors. + """ + try: + tool_key = f"tool_{id(tool)}_{id(tool_args)}" + invocation = self._active_tool_invocations.pop(tool_key, None) + + if invocation: + # Fail invocation (sets error attributes and ends span) + self._handler.fail_execute_tool( + invocation, Error(message=str(error), type=type(error)) + ) + + _logger.debug(f"Handled Tool error: {error}") + + except Exception as e: + _logger.exception(f"Error in on_tool_error_callback: {e}") + + return None + + # ===== Helper Methods ===== + + @staticmethod + def _extract_text_from_content(content: Any) -> str: + """ + Extract text from ADK content objects. + + Handles various content types: plain strings, Content objects with + parts/text attributes, and other objects (converted via str()). + + Args: + content: Content object (could be types.Content, string, etc.) + + Returns: + Extracted text string + """ + if not content: + return "" + if isinstance(content, str): + return content + if hasattr(content, "parts") and content.parts: + text_parts = [] + for part in content.parts: + if hasattr(part, "text") and part.text: + text_parts.append(part.text) + return "".join(text_parts) + if hasattr(content, "text"): + return content.text or "" + return str(content) + + def _resolve_model_name( + self, + llm_response: LlmResponse, + request_key: str, + invocation: LLMInvocation, + ) -> str: + """ + Resolve model name with robust fallbacks. + + Args: + llm_response: LLM response object + request_key: Request key for stored models + invocation: LLMInvocation object + + Returns: + Model name string + """ + model_name = None + + # 1) Prefer llm_response.model if available + if ( + llm_response + and hasattr(llm_response, "model") + and getattr(llm_response, "model") + ): + model_name = getattr(llm_response, "model") + + # 2) Use stored request model by request_key + if ( + not model_name + and request_key + and request_key in self._llm_req_models + ): + model_name = self._llm_req_models.pop(request_key, None) + + # 3) Use invocation request_model + if not model_name and invocation and invocation.request_model: + model_name = invocation.request_model + + # 4) Final fallback + if not model_name: + model_name = "unknown" + + return model_name + + def _convert_user_message_to_input_messages( + self, user_message: types.Content + ) -> List[InputMessage]: + """ + Convert ADK user message to GenAI InputMessage format. + + Args: + user_message: ADK Content object + + Returns: + List of InputMessage objects + """ + input_messages = [] + if ( + user_message + and hasattr(user_message, "role") + and hasattr(user_message, "parts") + ): + parts = [] + for part in user_message.parts: + if hasattr(part, "text"): + parts.append(Text(content=part.text)) + if parts: + input_messages.append( + InputMessage(role=user_message.role, parts=parts) + ) + return input_messages + + def _convert_contents_to_input_messages( + self, contents: List[types.Content] + ) -> List[InputMessage]: + """ + Convert ADK contents to GenAI InputMessage format. + + Args: + contents: List of ADK Content objects + + Returns: + List of InputMessage objects + """ + input_messages = [] + for content in contents: + if hasattr(content, "role") and hasattr(content, "parts"): + parts = [] + for part in content.parts: + if hasattr(part, "text"): + parts.append(Text(content=part.text)) + if parts: + input_messages.append( + InputMessage(role=content.role, parts=parts) + ) + return input_messages + + def _convert_llm_response_to_output_messages( + self, llm_response: LlmResponse + ) -> List[OutputMessage]: + """ + Convert ADK LlmResponse to GenAI OutputMessage format. + + Args: + llm_response: ADK LlmResponse object + + Returns: + List of OutputMessage objects + """ + output_messages = [] + + if not llm_response: + return output_messages + + try: + # Extract finish reason + finish_reason = ( + getattr(llm_response, "finish_reason", None) or "stop" + ) + if hasattr(finish_reason, "value"): + finish_reason = finish_reason.value + elif not isinstance(finish_reason, (str, int, float, bool)): + finish_reason = str(finish_reason) + + # Check if response has text content + if hasattr(llm_response, "text") and llm_response.text is not None: + extracted_text = self._extract_text_from_content( + llm_response.text + ) + if extracted_text: + output_messages.append( + OutputMessage( + role="assistant", + parts=[Text(content=extracted_text)], + finish_reason=finish_reason, + ) + ) + elif ( + hasattr(llm_response, "content") + and llm_response.content is not None + ): + extracted_text = self._extract_text_from_content( + llm_response.content + ) + if extracted_text: + output_messages.append( + OutputMessage( + role="assistant", + parts=[Text(content=extracted_text)], + finish_reason=finish_reason, + ) + ) + except Exception as e: + _logger.debug(f"Failed to extract output messages: {e}") + + return output_messages diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py new file mode 100644 index 000000000..ad5a056b4 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/package.py @@ -0,0 +1,20 @@ +# 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 = ("google-adk >= 0.1.0",) + +_supports_metrics = True + +_semconv_status = "experimental" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py new file mode 100644 index 000000000..a5e99969e --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/src/opentelemetry/instrumentation/google_adk/version.py @@ -0,0 +1,3 @@ +"""Version information for OpenTelemetry Google ADK Instrumentation.""" + +__version__ = "0.1.0" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py new file mode 100644 index 000000000..7eb688859 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for OpenTelemetry Google ADK Instrumentation.""" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_agent_invocation_creates_agent_span.yaml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_agent_invocation_creates_agent_span.yaml new file mode 100644 index 000000000..f0c2249db --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_agent_invocation_creates_agent_span.yaml @@ -0,0 +1,256 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Tell me a joke" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '677' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: |- + { + "choices": [ + { + "message": { + "content": "I don't have a joke function available, but I'd be happy to help with other tasks like calculating sums or checking the current time!", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null + } + ], + "object": "chat.completion", + "usage": { + "prompt_tokens": 234, + "completion_tokens": 28, + "total_tokens": 262, + "prompt_tokens_details": { + "cached_tokens": 0 + } + }, + "created": 1769765237, + "system_fingerprint": null, + "model": "qwen-plus", + "id": "chatcmpl-8e1156ac-c647-961c-b764-9e76a962d7df" + } + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:16 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765235909' + req-cost-time: + - '919' + resp-start-time: + - '1769765236829' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '919' + x-request-id: + - 8e1156ac-c647-961c-b764-9e76a962d7df + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Tell me a joke" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '677' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: '' + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:16 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765235909' + req-cost-time: + - '919' + resp-start-time: + - '1769765236829' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '919' + x-request-id: + - 8e1156ac-c647-961c-b764-9e76a962d7df + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_error_handling_creates_error_spans.yaml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_error_handling_creates_error_spans.yaml new file mode 100644 index 000000000..cb66efc50 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_error_handling_creates_error_spans.yaml @@ -0,0 +1,256 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '668' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: |- + { + "choices": [ + { + "message": { + "content": "Hello! How can I assist you today?", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null + } + ], + "object": "chat.completion", + "usage": { + "prompt_tokens": 231, + "completion_tokens": 9, + "total_tokens": 240, + "prompt_tokens_details": { + "cached_tokens": 0 + } + }, + "created": 1769765242, + "system_fingerprint": null, + "model": "qwen-plus", + "id": "chatcmpl-c57bc8b7-83dd-9560-b45f-3f74fdb9c138" + } + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:21 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765240892' + req-cost-time: + - '696' + resp-start-time: + - '1769765241589' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '696' + x-request-id: + - c57bc8b7-83dd-9560-b45f-3f74fdb9c138 + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '668' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: '' + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:21 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765240892' + req-cost-time: + - '696' + resp-start-time: + - '1769765241589' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '696' + x-request-id: + - c57bc8b7-83dd-9560-b45f-3f74fdb9c138 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_llm_call_creates_chat_span.yaml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_llm_call_creates_chat_span.yaml new file mode 100644 index 000000000..99c6405c6 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_llm_call_creates_chat_span.yaml @@ -0,0 +1,256 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello, how are you?" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '682' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: |- + { + "choices": [ + { + "message": { + "content": "Hello! I'm doing well, thank you for asking. I'm the test agent for instrumentation, ready to help with any tasks or questions you might have. How can I assist you today?", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null + } + ], + "object": "chat.completion", + "usage": { + "prompt_tokens": 236, + "completion_tokens": 39, + "total_tokens": 275, + "prompt_tokens_details": { + "cached_tokens": 0 + } + }, + "created": 1769765234, + "system_fingerprint": null, + "model": "qwen-plus", + "id": "chatcmpl-d3fff81d-f471-95d1-b992-005925180210" + } + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:13 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765232516' + req-cost-time: + - '989' + resp-start-time: + - '1769765233506' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '989' + x-request-id: + - d3fff81d-f471-95d1-b992-005925180210 + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello, how are you?" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '682' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: '' + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:13 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765232516' + req-cost-time: + - '989' + resp-start-time: + - '1769765233506' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '989' + x-request-id: + - d3fff81d-f471-95d1-b992-005925180210 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_metrics_are_recorded.yaml b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_metrics_are_recorded.yaml new file mode 100644 index 000000000..b858a3639 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/cassettes/TestGoogleAdkSDKIntegration.test_metrics_are_recorded.yaml @@ -0,0 +1,256 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '668' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: |- + { + "choices": [ + { + "message": { + "content": "Hello! How can I assist you today?", + "role": "assistant" + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null + } + ], + "object": "chat.completion", + "usage": { + "prompt_tokens": 231, + "completion_tokens": 9, + "total_tokens": 240, + "prompt_tokens_details": { + "cached_tokens": 0 + } + }, + "created": 1769765238, + "system_fingerprint": null, + "model": "qwen-plus", + "id": "chatcmpl-72fde1ac-a61a-9992-b2fe-6627548cbff2" + } + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:17 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765237649' + req-cost-time: + - '474' + resp-start-time: + - '1769765238124' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '474' + x-request-id: + - 72fde1ac-a61a-9992-b2fe-6627548cbff2 + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "role": "system", + "content": "You are a helpful assistant.\n\nYou are an agent. Your internal name is \"test_agent\". The description about you is \"Test agent for instrumentation\"." + }, + { + "role": "user", + "content": "Hello" + } + ], + "model": "qwen-plus", + "max_tokens": 100, + "temperature": 0.7, + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "Get current time as a simple tool.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + { + "type": "function", + "function": { + "name": "calculate_sum", + "description": "Calculate sum of two numbers.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "integer" + }, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "b" + ] + } + } + } + ] + } + headers: + Authorization: + - Bearer test_api_key + accept: + - application/json + accept-encoding: + - gzip, deflate + connection: + - keep-alive + content-length: + - '668' + content-type: + - application/json + host: + - dashscope.aliyuncs.com + user-agent: + - AsyncOpenAI/Python 2.16.0 + x-stainless-arch: + - arm64 + x-stainless-async: + - async:asyncio + x-stainless-lang: + - python + x-stainless-os: + - MacOS + x-stainless-package-version: + - 2.16.0 + x-stainless-raw-response: + - 'true' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.0 + method: POST + uri: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + response: + body: + string: '' + headers: + Content-Type: + - application/json + Date: + - Fri, 30 Jan 2026 09:27:17 GMT + Server: + - istio-envoy + Vary: + - Origin,Access-Control-Request-Method,Access-Control-Request-Headers, Accept-Encoding + req-arrive-time: + - '1769765237649' + req-cost-time: + - '474' + resp-start-time: + - '1769765238124' + x-dashscope-call-gateway: + - 'true' + x-envoy-upstream-service-time: + - '474' + x-request-id: + - 72fde1ac-a61a-9992-b2fe-6627548cbff2 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/conftest.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/conftest.py new file mode 100644 index 000000000..6ea4f3e94 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/conftest.py @@ -0,0 +1,425 @@ +# -*- coding: utf-8 -*- +"""Test Configuration""" + +import json +import os +from typing import List + +import pytest +import yaml + +# Set up DASHSCOPE_API_KEY environment variable BEFORE any dashscope modules are imported +# This is critical because dashscope SDK reads environment variables at module import time +# and caches them in module-level variables +if "DASHSCOPE_API_KEY" not in os.environ: + os.environ["DASHSCOPE_API_KEY"] = "test_api_key" + +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +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: pytest.Config): + # Configure pytest-asyncio to auto-detect async test functions + config.option.asyncio_mode = "auto" + + # Set necessary environment variables + os.environ["JUPYTER_PLATFORM_DIRS"] = "1" + + # Set GenAI semantic conventions to latest experimental version + os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] = "gen_ai_latest_experimental" + + api_key = os.getenv("DASHSCOPE_API_KEY") + + if api_key is None: + pytest.exit( + "Environment variable 'DASHSCOPE_API_KEY' is not set. Aborting tests." + ) + else: + # Save environment variable to global config for use in subsequent tests + config.option.api_key = api_key + + +# ==================== Exporters and Readers ==================== + + +@pytest.fixture(scope="function", name="span_exporter") +def fixture_span_exporter(): + """Create in-memory span exporter""" + exporter = InMemorySpanExporter() + yield exporter + + +@pytest.fixture(scope="function", name="log_exporter") +def fixture_log_exporter(): + """Create in-memory log exporter""" + exporter = InMemoryLogExporter() + yield exporter + + +@pytest.fixture(scope="function", name="metric_reader") +def fixture_metric_reader(): + """Create in-memory metric reader""" + reader = InMemoryMetricReader() + yield reader + + +# ==================== Providers ==================== + + +@pytest.fixture(scope="function", name="tracer_provider") +def fixture_tracer_provider(span_exporter): + """Create tracer provider""" + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture(scope="function", name="logger_provider") +def fixture_logger_provider(log_exporter): + """Create logger provider""" + provider = LoggerProvider() + provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter)) + return provider + + +@pytest.fixture(scope="function", name="meter_provider") +def fixture_meter_provider(metric_reader): + """Create meter provider""" + meter_provider = MeterProvider( + metric_readers=[metric_reader], + ) + return meter_provider + + +# ==================== Instrumentation Fixtures ==================== + + +@pytest.fixture(scope="function") +def instrument(tracer_provider, logger_provider, meter_provider): + """Instrument Google ADK with default settings""" + instrumentor = GoogleAdkInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_no_content(tracer_provider, logger_provider, meter_provider): + """Instrument without capturing message content""" + os.environ.update( + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "NO_CONTENT"} + ) + + instrumentor = GoogleAdkInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_with_content(tracer_provider, logger_provider, meter_provider): + """Instrument with capturing message content in spans only""" + os.environ.update( + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"} + ) + + instrumentor = GoogleAdkInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_with_content_and_events( + tracer_provider, logger_provider, meter_provider +): + """Instrument with capturing message content in both spans and events""" + os.environ.update( + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_AND_EVENT"} + ) + + instrumentor = GoogleAdkInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + instrumentor.uninstrument() + + +# ==================== Helper Functions ==================== + + +def find_spans_by_operation(spans: List, operation_name: str) -> List: + """ + Find spans by operation name. + + Args: + spans: List of spans to search + operation_name: Operation name to search for (e.g., "chat", "invoke_agent") + + Returns: + List of spans matching the operation name + """ + return [ + span + for span in spans + if span.attributes.get("gen_ai.operation.name") == operation_name + ] + + +def find_spans_by_name_prefix(spans: List, prefix: str) -> List: + """ + Find spans by name prefix. + + Args: + spans: List of spans to search + prefix: Prefix to match against span names + + Returns: + List of spans with names starting with the prefix + """ + return [span for span in spans if span.name.startswith(prefix)] + + +def print_span_tree(spans: List, indent: int = 0): + """ + Print span hierarchy as a tree structure. + + Args: + spans: List of spans to print + indent: Current indentation level + """ + # Build a map of span_id -> span for quick lookup + span_map = {span.context.span_id: span for span in spans} + + # Find root spans (spans with no parent or parent not in the list) + root_spans = [ + span + for span in spans + if span.parent is None or span.parent.span_id not in span_map + ] + + def print_span_recursive(span, level=0): + """Recursively print span and its children""" + prefix = " " * level + operation = span.attributes.get("gen_ai.operation.name", "unknown") + print(f"{prefix}- {span.name} ({operation})") + + # Find children + children = [ + s + for s in spans + if s.parent is not None + and s.parent.span_id == span.context.span_id + ] + + for child in children: + print_span_recursive(child, level + 1) + + for root_span in root_spans: + print_span_recursive(root_span, indent) + + +# ==================== VCR Configuration ==================== + + +class LiteralBlockScalar(str): + """Format string as literal block scalar, preserving whitespace and not interpreting escape characters""" + + +def literal_block_scalar_presenter(dumper, data): + """Represent scalar string as literal block using '|' syntax""" + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + + +yaml.add_representer(LiteralBlockScalar, literal_block_scalar_presenter) + + +def process_string_value(string_value): + """Format JSON or return long string as LiteralBlockScalar""" + import gzip # noqa: PLC0415 + + # Handle bytes type - decompress if gzip, then decode + if isinstance(string_value, bytes): + try: + # Check if gzip compressed (magic number 0x1f 0x8b) + if string_value[:2] == b"\x1f\x8b": + string_value = gzip.decompress(string_value).decode("utf-8") + else: + string_value = string_value.decode("utf-8") + except Exception: + # If we can't decode, return as-is (will be handled by YAML) + return string_value + + try: + json_data = json.loads(string_value) + return LiteralBlockScalar(json.dumps(json_data, indent=2)) + except (ValueError, TypeError): + if len(string_value) > 80: + return LiteralBlockScalar(string_value) + return string_value + + +def convert_body_to_literal(data): + """Search for body strings in data and attempt to format JSON""" + if isinstance(data, dict): + for key, value in data.items(): + # Handle response body case (e.g., response.body.string) + if key == "body" and isinstance(value, dict) and "string" in value: + value["string"] = process_string_value(value["string"]) + + # Handle request body case (e.g., request.body) + elif key == "body" and isinstance(value, str): + data[key] = process_string_value(value) + + else: + convert_body_to_literal(value) + + elif isinstance(data, list): + for idx, choice in enumerate(data): + data[idx] = convert_body_to_literal(choice) + + return data + + +class PrettyPrintJSONBody: + """Make request and response body recordings more readable""" + + @staticmethod + def serialize(cassette_dict): + cassette_dict = convert_body_to_literal(cassette_dict) + return yaml.dump( + cassette_dict, default_flow_style=False, allow_unicode=True + ) + + @staticmethod + def deserialize(cassette_string): + return yaml.load(cassette_string, Loader=yaml.Loader) + + +@pytest.fixture(scope="module", autouse=True) +def fixture_vcr(vcr): + """Register VCR serializer and custom matcher""" + # Note: Not using custom PrettyPrintJSONBody serializer for httpx compatibility + # vcr.register_serializer("yaml", PrettyPrintJSONBody) + return vcr + + +def normalize_request_headers(request): + """ + Normalize request headers by removing dynamic headers that change between requests. + + This function removes headers like x-stainless-retry-count that may differ + between recording and playback, causing VCR matching failures. + + Args: + request: VCR request object + + Returns: + Request object with normalized headers + """ + if hasattr(request, "headers") and request.headers: + # Remove dynamic headers that may differ between runs + headers_to_remove = [ + "x-stainless-retry-count", + "X-Stainless-Retry-Count", + "x-stainless-read-timeout", + "X-Stainless-Read-Timeout", + ] + for header_name in headers_to_remove: + if header_name in request.headers: + del request.headers[header_name] + return request + + +def scrub_response_headers(response): + """ + Scrub sensitive response headers. Note they are case-sensitive! + """ + # Clean response headers as needed + if "headers" in response: + headers = response["headers"] + # Remove headers that can cause issues when replaying pretty-printed JSON + if "Content-Length" in headers: + del headers["Content-Length"] + if "content-length" in headers: + del headers["content-length"] + if "Transfer-Encoding" in headers: + del headers["Transfer-Encoding"] + if "transfer-encoding" in headers: + del headers["transfer-encoding"] + if "Content-Encoding" in headers: + del headers["Content-Encoding"] + if "content-encoding" in headers: + del headers["content-encoding"] + + if "Set-Cookie" in response.get("headers", {}): + response["headers"]["Set-Cookie"] = "test_set_cookie" + return response + + +@pytest.fixture(scope="module") +def vcr_config(): + """Configure VCR for recording and replaying HTTP requests""" + return { + "filter_headers": [ + ("authorization", "Bearer test_api_key"), + ("api-key", "test_api_key"), + ("x-api-key", "test_api_key"), + ("Authorization", "Bearer test_api_key"), + ("X-Api-Key", "test_api_key"), + # Filter out dynamic headers that change between requests + # These headers are added by OpenAI SDK and may differ between runs + ("x-stainless-retry-count", None), # Remove retry count header + ("X-Stainless-Retry-Count", None), + ("x-stainless-read-timeout", None), + ("X-Stainless-Read-Timeout", None), + ], + "filter_query_parameters": ["api_key"], + "filter_post_data_parameters": ["api_key"], + # Note: decode_compressed_response and before_record_response removed + # for httpx compatibility - VCR 7.x handles these internally + "before_record_request": normalize_request_headers, + # Ignore LiteLLM's model price fetching requests to avoid matching issues + # These requests are made by LiteLLM internally and don't affect our tests + "ignore_hosts": [ + "raw.githubusercontent.com" + ], # Ignore LiteLLM model price requests + # Allow recording new interactions when cassette exists + "record_mode": "new_episodes", + } diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.latest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.latest.txt new file mode 100644 index 000000000..4746b55c5 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.latest.txt @@ -0,0 +1,14 @@ +# Test requirements for latest versions +google-adk>=0.1.0 +litellm +pytest +pytest-asyncio +pytest-cov +pytest-vcr>=1.0.2 +vcrpy>=5.1.0 +pyyaml>=6.0 +wrapt + +-e opentelemetry-instrumentation +-e instrumentation-loongsuite/loongsuite-instrumentation-google-adk +-e util/opentelemetry-util-genai diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.oldest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.oldest.txt new file mode 100644 index 000000000..f6b6eee5f --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.oldest.txt @@ -0,0 +1,17 @@ +# Test requirements for oldest supported versions +google-adk>=0.1.0 +litellm +pytest +pytest-asyncio +pytest-cov +pytest-vcr>=1.0.2 +vcrpy>=5.1.0 +pyyaml>=6.0 +wrapt +opentelemetry-api==1.37 +opentelemetry-sdk==1.37 +opentelemetry-instrumentation==0.58b0 +opentelemetry-semantic-conventions==0.58b0 + +-e instrumentation-loongsuite/loongsuite-instrumentation-google-adk +-e util/opentelemetry-util-genai diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_integration.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_integration.py new file mode 100644 index 000000000..30a169b2f --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_integration.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +""" +Integration tests for Google ADK Instrumentation using real SDK. + +These tests use the actual Google ADK SDK to verify that instrumentation +works correctly with real API calls. They follow TDD principles and will +be used to validate the migration from direct trace/metrics implementation +to util-genai based implementation. +""" + +import asyncio +import os + +import pytest +from google.adk.agents import LlmAgent +from google.adk.models.lite_llm import LiteLlm +from google.adk.runners import Runner +from google.adk.sessions.in_memory_session_service import ( + InMemorySessionService, +) +from google.adk.tools import FunctionTool +from google.genai import types + +from .conftest import ( + find_spans_by_operation, +) + +# Test configuration +DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "test_api_key") +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +DASHSCOPE_MODEL = "dashscope/qwen-plus" + + +# Simple tool functions for testing +# Use fixed return values to ensure VCR cassette matching +def get_current_time() -> str: + """Get current time as a simple tool.""" + # Return fixed time for VCR cassette matching + return "2024-01-01 12:00:00" + + +def calculate_sum(a: int, b: int) -> int: + """Calculate sum of two numbers.""" + return a + b + + +class TestGoogleAdkSDKIntegration: + """Integration tests using real Google ADK SDK.""" + + @pytest.fixture(scope="function") + def session_service(self): + """Create session service for tests.""" + return InMemorySessionService() + + @pytest.fixture(scope="function") + def model(self): + """Create LiteLlm model instance with fixed configuration for VCR matching.""" + return LiteLlm( + model=DASHSCOPE_MODEL, + api_key=DASHSCOPE_API_KEY, + base_url=DASHSCOPE_BASE_URL, + temperature=0.7, # Fixed temperature for VCR matching + max_tokens=100, # Fixed max_tokens for VCR matching + ) + + @pytest.fixture(scope="function") + def agent(self, model): + """Create LlmAgent instance with tools. + + Uses fixed configuration to ensure VCR cassette matching: + - Fixed agent name: "test_agent" + - Fixed instruction and description + - Fixed tool functions + """ + time_tool = FunctionTool(func=get_current_time) + calc_tool = FunctionTool(func=calculate_sum) + + agent = LlmAgent( + name="test_agent", # Fixed name for VCR matching + model=model, + instruction="You are a helpful assistant.", # Fixed instruction + description="Test agent for instrumentation", # Fixed description + tools=[time_tool, calc_tool], # Fixed tools + ) + return agent + + @pytest.fixture(scope="function") + def runner(self, agent, session_service, instrument): + """Create Runner instance after instrumentation is applied. + + Note: Runner must be created AFTER instrumentation to ensure + the plugin is injected into Runner.__init__. + + Uses fixed app_name for VCR matching. + """ + return Runner( + app_name="test_app", # Fixed app_name for VCR matching + agent=agent, + session_service=session_service, + ) + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_llm_call_creates_chat_span( + self, instrument, span_exporter, runner, session_service + ): + """ + Test that LLM calls create chat spans. + + Expected spans: + - invoke_agent {app_name} (Runner) + - chat {model} (LLM) + """ + # Create session + session = await session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="test_session_2", + ) + + # Create user message + user_message = types.Content( + role="user", parts=[types.Part(text="Hello, how are you?")] + ) + + # Clear spans before test + span_exporter.clear() + + # Run conversation + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id=session.id, + new_message=user_message, + ): + events.append(event) + + # Wait a bit for spans to be exported + await asyncio.sleep(0.5) + + # Get finished spans + spans = span_exporter.get_finished_spans() + + # Should have chat span + chat_spans = find_spans_by_operation(spans, "chat") + assert len(chat_spans) >= 1, "Should have at least one chat span" + + # Verify chat span attributes + chat_span = chat_spans[0] + assert chat_span.attributes.get("gen_ai.operation.name") == "chat" + assert chat_span.attributes.get("gen_ai.provider.name") is not None + assert chat_span.attributes.get("gen_ai.request.model") is not None + assert chat_span.name.startswith("chat ") + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_agent_invocation_creates_agent_span( + self, instrument, span_exporter, runner, session_service + ): + """ + Test that Agent invocation creates invoke_agent spans. + + Expected spans: + - invoke_agent {app_name} (Runner) + - invoke_agent {agent_name} (Agent) + """ + # Create session with fixed IDs for VCR matching + session = await session_service.create_session( + app_name="test_app", # Fixed app_name + user_id="test_user", # Fixed user_id + session_id="test_session_4", # Fixed session_id + ) + + # Create user message with fixed content for VCR matching + user_message = types.Content( + role="user", + parts=[types.Part(text="Tell me a joke")], # Fixed message content + ) + + # Clear spans before test + span_exporter.clear() + + # Run conversation + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id=session.id, + new_message=user_message, + ): + events.append(event) + + # Wait a bit for spans to be exported + await asyncio.sleep(0.5) + + # Get finished spans + spans = span_exporter.get_finished_spans() + + # Should have agent spans + agent_spans = find_spans_by_operation(spans, "invoke_agent") + assert len(agent_spans) >= 1, ( + "Should have at least one invoke_agent span" + ) + + # Verify agent span attributes + agent_span = agent_spans[0] + assert ( + agent_span.attributes.get("gen_ai.operation.name") + == "invoke_agent" + ) + assert ( + agent_span.attributes.get("gen_ai.provider.name") == "google_adk" + ) + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_metrics_are_recorded( + self, instrument, span_exporter, metric_reader, runner, session_service + ): + """ + Test that metrics are recorded for operations. + + Expected metrics: + - gen_ai.client.operation.duration + - gen_ai.client.token.usage (if tokens are available) + """ + # Create session with fixed IDs for VCR matching + session = await session_service.create_session( + app_name="test_app", # Fixed app_name + user_id="test_user", # Fixed user_id + session_id="test_session_5", # Fixed session_id + ) + + # Create user message with fixed content for VCR matching + user_message = types.Content( + role="user", + parts=[types.Part(text="Hello")], # Fixed message content + ) + + # Clear before test + span_exporter.clear() + + # Run conversation + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id=session.id, + new_message=user_message, + ): + events.append(event) + + # Wait a bit for metrics to be recorded + await asyncio.sleep(0.5) + + # Get metrics + metrics = metric_reader.get_metrics_data() + + # Should have operation duration metrics + # Note: Metrics may be recorded asynchronously, so we check if any metrics exist + assert metrics is not None, "Should have metrics data" + + @pytest.mark.asyncio + @pytest.mark.vcr() + async def test_error_handling_creates_error_spans( + self, instrument, span_exporter, runner, session_service + ): + """ + Test that errors are properly handled and recorded in spans. + + This test may need to be adjusted based on how errors are triggered. + """ + # Create session + session = await session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="test_session_7", + ) + + # Create user message + user_message = types.Content( + role="user", parts=[types.Part(text="Hello")] + ) + + # Clear spans before test + span_exporter.clear() + + # Run conversation (should succeed) + events = [] + try: + async for event in runner.run_async( + user_id="test_user", + session_id=session.id, + new_message=user_message, + ): + events.append(event) + except Exception: + # If error occurs, verify it's recorded + await asyncio.sleep(0.5) + spans = span_exporter.get_finished_spans() + + # Check if any span has error status + error_spans = [ + span + for span in spans + if span.status.status_code.value == 2 # ERROR status + ] + + # If errors occurred, they should be recorded + if error_spans: + error_span = error_spans[0] + assert "error.type" in error_span.attributes + + # For now, just verify spans are created + await asyncio.sleep(0.5) + spans = span_exporter.get_finished_spans() + assert len(spans) >= 1, "Should have at least one span" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py new file mode 100644 index 000000000..371714fe2 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_metrics.py @@ -0,0 +1,455 @@ +""" +Integration tests for Google ADK Metrics with InMemoryMetricReader validation. + +Tests validate that metrics are recorded with correct attributes according to +OpenTelemetry GenAI Semantic Conventions using real plugin callbacks and +InMemoryMetricReader to capture actual metrics data. + +This test follows the same pattern as the commercial ARMS version but validates +against the latest OpenTelemetry GenAI semantic conventions. +""" + +import asyncio +from typing import Any, Dict, List +from unittest.mock import Mock + +import pytest + +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor +from opentelemetry.sdk import metrics as metrics_sdk +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +def create_mock_callback_context(session_id="session_123", user_id="user_456"): + """Create properly structured mock CallbackContext.""" + mock_callback_context = Mock() + mock_session = Mock() + mock_session.id = session_id + mock_invocation_context = Mock() + mock_invocation_context.session = mock_session + mock_callback_context._invocation_context = mock_invocation_context + mock_callback_context.user_id = user_id + return mock_callback_context + + +class OTelGenAIMetricsValidator: + """ + Validator for OpenTelemetry GenAI Metrics Semantic Conventions. + + Based on the latest OTel GenAI semantic conventions: + - Only 2 standard metrics: gen_ai.client.operation.duration and gen_ai.client.token.usage + - Required attributes: gen_ai.operation.name, gen_ai.provider.name + - Recommended attributes: gen_ai.request.model, gen_ai.response.model, server.address, server.port + - error.type only present on error + - gen_ai.token.type with values "input" or "output" for token usage + """ + + # Standard OTel GenAI metrics + STANDARD_METRICS = { + "gen_ai.client.operation.duration", # Histogram + "gen_ai.client.token.usage", # Histogram + } + + def validate_metrics_data( + self, metric_reader: InMemoryMetricReader + ) -> Dict[str, Any]: + """Validate metrics data against OTel GenAI conventions.""" + validation_result = { + "metrics_found": set(), + "metric_validations": {}, + "errors": [], + "warnings": [], + } + + metrics_data = metric_reader.get_metrics_data() + if not metrics_data: + validation_result["warnings"].append("No metrics data found") + return validation_result + + # Collect all found metrics + for resource_metrics in metrics_data.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + validation_result["metrics_found"].add(metric.name) + + # Validate individual metric + validation_result["metric_validations"][metric.name] = ( + self._validate_single_metric(metric) + ) + + return validation_result + + def _validate_single_metric(self, metric) -> Dict[str, Any]: + """Validate a single metric's attributes and data.""" + result = { + "name": metric.name, + "type": type(metric.data).__name__, + "data_points": [], + "errors": [], + "warnings": [], + } + + # Get data points + data_points = [] + if hasattr(metric.data, "data_points"): + data_points = metric.data.data_points + + for data_point in data_points: + point_validation = self._validate_data_point( + metric.name, data_point + ) + result["data_points"].append(point_validation) + if point_validation["errors"]: + result["errors"].extend(point_validation["errors"]) + + return result + + def _validate_data_point( + self, metric_name: str, data_point + ) -> Dict[str, Any]: + """Validate data point attributes against OTel GenAI conventions.""" + result = { + "attributes": {}, + "value": None, + "errors": [], + "warnings": [], + } + + # Extract attributes + if hasattr(data_point, "attributes"): + result["attributes"] = ( + dict(data_point.attributes) if data_point.attributes else {} + ) + + # Extract value + if hasattr(data_point, "sum"): + result["value"] = data_point.sum + elif hasattr(data_point, "count"): + result["value"] = data_point.count + + # Validate OTel GenAI attributes + attributes = result["attributes"] + + # Check required attributes + if "gen_ai.operation.name" not in attributes: + result["errors"].append( + "Missing required attribute: gen_ai.operation.name" + ) + + if "gen_ai.provider.name" not in attributes: + result["errors"].append( + "Missing required attribute: gen_ai.provider.name" + ) + + # Validate token.type values + if "gen_ai.token.type" in attributes: + token_type = attributes["gen_ai.token.type"] + if token_type not in ["input", "output"]: + result["errors"].append( + f"Invalid gen_ai.token.type value: {token_type}" + ) + + return result + + +class TestGoogleAdkMetricsIntegration: + """Integration tests using InMemoryMetricReader to validate actual metrics.""" + + def setup_method(self): + """Set up test fixtures for each test.""" + # Create independent providers and readers + self.tracer_provider = trace_sdk.TracerProvider() + self.span_exporter = InMemorySpanExporter() + self.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + + self.metric_reader = InMemoryMetricReader() + self.meter_provider = metrics_sdk.MeterProvider( + metric_readers=[self.metric_reader] + ) + + # Create instrumentor + self.instrumentor = GoogleAdkInstrumentor() + + # Create validator + self.validator = OTelGenAIMetricsValidator() + + # Clean up any existing instrumentation + if self.instrumentor.is_instrumented_by_opentelemetry: + self.instrumentor.uninstrument() + + # Clear any existing data + self.span_exporter.clear() + + def teardown_method(self): + """Clean up after each test.""" + try: + if self.instrumentor.is_instrumented_by_opentelemetry: + self.instrumentor.uninstrument() + except Exception: + pass + + self.span_exporter.clear() + + def get_metrics_by_name(self, name: str) -> List[Any]: + """Get metrics data by metric name from InMemoryMetricReader.""" + metrics_data = self.metric_reader.get_metrics_data() + if not metrics_data: + return [] + + found_metrics = [] + for resource_metrics in metrics_data.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + if metric.name == name: + found_metrics.append(metric) + + return found_metrics + + def get_metric_data_points(self, metric_name: str) -> List[Any]: + """Get data points for a specific metric.""" + metrics = self.get_metrics_by_name(metric_name) + if not metrics: + return [] + + data_points = [] + for metric in metrics: + if hasattr(metric.data, "data_points"): + data_points.extend(metric.data.data_points) + + return data_points + + @pytest.mark.asyncio + async def test_llm_metrics_with_standard_otel_attributes(self): + """ + Test that LLM metrics are recorded with standard OTel GenAI attributes. + + Validates: + - gen_ai.client.operation.duration histogram recorded + - gen_ai.client.token.usage histogram recorded + - Required attributes present: gen_ai.operation.name, gen_ai.provider.name + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock LLM request and response + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.config.max_tokens = 1000 + mock_llm_request.config.temperature = 0.7 + mock_llm_request.contents = ["test"] + + mock_llm_response = Mock() + mock_llm_response.model = "gemini-pro" + mock_llm_response.finish_reason = "stop" + mock_llm_response.usage_metadata = Mock() + mock_llm_response.usage_metadata.prompt_token_count = 100 + mock_llm_response.usage_metadata.candidates_token_count = 50 + + mock_callback_context = create_mock_callback_context() + + # Execute LLM callbacks + await plugin.before_model_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + ) + + await asyncio.sleep(0.01) # Simulate processing time + + await plugin.after_model_callback( + callback_context=mock_callback_context, + llm_response=mock_llm_response, + ) + + # Validate metrics using InMemoryMetricReader + validation_result = self.validator.validate_metrics_data( + self.metric_reader + ) + + # Check standard metrics are present + assert ( + "gen_ai.client.operation.duration" + in validation_result["metrics_found"] + ), "Should have gen_ai.client.operation.duration metric" + assert ( + "gen_ai.client.token.usage" in validation_result["metrics_found"] + ), "Should have gen_ai.client.token.usage metric" + + # Get actual data points + duration_points = self.get_metric_data_points( + "gen_ai.client.operation.duration" + ) + assert len(duration_points) >= 1, ( + "Should have at least 1 duration data point" + ) + + # Validate duration attributes + duration_attrs = dict(duration_points[0].attributes) + assert duration_attrs.get("gen_ai.operation.name") == "chat", ( + "Should have gen_ai.operation.name = 'chat'" + ) + assert "gen_ai.provider.name" in duration_attrs, ( + "Should have gen_ai.provider.name" + ) + assert duration_attrs.get("gen_ai.request.model") == "gemini-pro", ( + "Should have gen_ai.request.model" + ) + + # Get token usage data points + token_points = self.get_metric_data_points("gen_ai.client.token.usage") + assert len(token_points) == 2, ( + "Should have 2 token usage data points (input + output)" + ) + + # Validate token types + token_types = { + dict(dp.attributes).get("gen_ai.token.type") for dp in token_points + } + assert token_types == { + "input", + "output", + }, "Should have both input and output token types" + + # Validate token values + input_point = [ + dp + for dp in token_points + if dict(dp.attributes).get("gen_ai.token.type") == "input" + ][0] + output_point = [ + dp + for dp in token_points + if dict(dp.attributes).get("gen_ai.token.type") == "output" + ][0] + + assert input_point.sum == 100, "Should record 100 input tokens" + assert output_point.sum == 50, "Should record 50 output tokens" + + @pytest.mark.asyncio + async def test_llm_metrics_with_error(self): + """ + Test that LLM error metrics include error.type attribute. + + Validates: + - error.type attribute present on error + - Standard attributes still present + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock LLM request + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.contents = None + + mock_callback_context = create_mock_callback_context() + + # Create error + test_error = Exception("API timeout") + + # Execute error scenario + await plugin.before_model_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + ) + + await plugin.on_model_error_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + error=test_error, + ) + + # Get metrics data + duration_points = self.get_metric_data_points( + "gen_ai.client.operation.duration" + ) + assert len(duration_points) >= 1, "Should have error duration metric" + + # Validate error.type attribute + error_attrs = dict(duration_points[0].attributes) + assert "error.type" in error_attrs, "Should have error.type on error" + assert error_attrs["error.type"] == "Exception" + + # NOTE: Agent and Tool metrics tests have been removed because + # ExtendedInvocationMetricsRecorder currently only supports LLM invocations. + # Agent and Tool operations will still create spans but not metrics. + + @pytest.mark.asyncio + async def test_only_two_standard_metrics_recorded(self): + """ + Test that the 2 standard OTel GenAI metrics are recorded for LLM operations. + + Validates: + - gen_ai.client.operation.duration is recorded + - gen_ai.client.token.usage is recorded + + Note: Currently only LLM operations record metrics. Agent and Tool operations + create spans but not metrics (not yet implemented in ExtendedInvocationMetricsRecorder). + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Execute LLM operation (only LLM metrics are supported) + mock_context = create_mock_callback_context() + + # LLM call + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.contents = ["test"] + + mock_llm_response = Mock() + mock_llm_response.model = "gemini-pro" + mock_llm_response.finish_reason = "stop" + mock_llm_response.usage_metadata = Mock() + mock_llm_response.usage_metadata.prompt_token_count = 10 + mock_llm_response.usage_metadata.candidates_token_count = 5 + + await plugin.before_model_callback( + callback_context=mock_context, llm_request=mock_llm_request + ) + await plugin.after_model_callback( + callback_context=mock_context, llm_response=mock_llm_response + ) + + # Validate metrics + validation_result = self.validator.validate_metrics_data( + self.metric_reader + ) + + # Should have exactly 2 standard metrics + standard_metrics = ( + validation_result["metrics_found"] + & self.validator.STANDARD_METRICS + ) + assert len(standard_metrics) == 2, ( + f"Should have exactly 2 standard metrics, got {len(standard_metrics)}: {standard_metrics}" + ) + + +# Run tests +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py new file mode 100644 index 000000000..343d9d6d9 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/test_plugin_integration.py @@ -0,0 +1,604 @@ +""" +Integration tests for Google ADK Plugin with InMemoryExporter validation. + +Tests validate that spans are created with correct attributes according to +OpenTelemetry GenAI Semantic Conventions using real plugin callbacks and +InMemorySpanExporter to capture actual span data. + +This test follows the same pattern as the commercial ARMS version but validates +against the latest OpenTelemetry GenAI semantic conventions. +""" + +from typing import Any, Dict +from unittest.mock import Mock + +import pytest + +from opentelemetry import trace as trace_api +from opentelemetry.instrumentation.google_adk import GoogleAdkInstrumentor +from opentelemetry.sdk import metrics as metrics_sdk +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +def create_mock_callback_context(session_id="session_123", user_id="user_456"): + """Create properly structured mock CallbackContext following ADK structure.""" + mock_callback_context = Mock() + mock_session = Mock() + mock_session.id = session_id + mock_invocation_context = Mock() + mock_invocation_context.session = mock_session + mock_callback_context._invocation_context = mock_invocation_context + mock_callback_context.user_id = user_id + return mock_callback_context + + +class OTelGenAISpanValidator: + """ + Validator for OpenTelemetry GenAI Semantic Conventions. + + Based on the latest OTel GenAI semantic conventions: + - gen_ai.provider.name (required, replaces gen_ai.system) + - gen_ai.operation.name (required, replaces gen_ai.span.kind) + - gen_ai.conversation.id (replaces gen_ai.session.id) + - enduser.id (replaces gen_ai.user.id) + - gen_ai.response.finish_reasons (array, replaces gen_ai.response.finish_reason) + - Tool attributes with gen_ai. prefix + - Agent attributes with gen_ai. prefix + """ + + # Required attributes for different operation types + REQUIRED_ATTRIBUTES_BY_OPERATION = { + "chat": { + "required": { + "gen_ai.operation.name", + "gen_ai.provider.name", + "gen_ai.request.model", + }, + "recommended": { + "gen_ai.response.model", + "gen_ai.usage.input_tokens", + "gen_ai.usage.output_tokens", + }, + }, + "invoke_agent": { + "required": {"gen_ai.operation.name"}, + "recommended": {"gen_ai.agent.name", "gen_ai.agent.description"}, + }, + "execute_tool": { + "required": {"gen_ai.operation.name", "gen_ai.tool.name"}, + "recommended": {"gen_ai.tool.description"}, + }, + } + + def validate_span(self, span, expected_operation: str) -> Dict[str, Any]: + """Validate a single span's attributes against OTel GenAI conventions.""" + validation_result = { + "span_name": span.name, + "expected_operation": expected_operation, + "errors": [], + "warnings": [], + "missing_required": [], + "missing_recommended": [], + } + + attributes = getattr(span, "attributes", {}) or {} + + # Validate operation name + actual_operation = attributes.get("gen_ai.operation.name") + if not actual_operation: + validation_result["errors"].append( + "Missing required attribute: gen_ai.operation.name" + ) + elif actual_operation != expected_operation: + validation_result["errors"].append( + f"Expected operation '{expected_operation}', got '{actual_operation}'" + ) + + # Validate required and recommended attributes + if expected_operation in self.REQUIRED_ATTRIBUTES_BY_OPERATION: + requirements = self.REQUIRED_ATTRIBUTES_BY_OPERATION[ + expected_operation + ] + + # Check required attributes + for attr in requirements["required"]: + if attr not in attributes: + validation_result["missing_required"].append(attr) + + # Check recommended attributes + for attr in requirements["recommended"]: + if attr not in attributes: + validation_result["missing_recommended"].append(attr) + + # Validate specific attribute formats + self._validate_attribute_formats(attributes, validation_result) + + return validation_result + + def _validate_attribute_formats(self, attributes: Dict, result: Dict): + """Validate attribute value formats and types.""" + + # Validate finish_reasons is array + if "gen_ai.response.finish_reasons" in attributes: + finish_reasons = attributes["gen_ai.response.finish_reasons"] + if not isinstance(finish_reasons, (list, tuple)): + result["errors"].append( + f"gen_ai.response.finish_reasons should be array, got {type(finish_reasons)}" + ) + + # Validate numeric attributes + numeric_attrs = [ + "gen_ai.request.max_tokens", + "gen_ai.usage.input_tokens", + "gen_ai.usage.output_tokens", + ] + for attr in numeric_attrs: + if attr in attributes and not isinstance( + attributes[attr], (int, float) + ): + result["errors"].append( + f"Attribute {attr} should be numeric, got {type(attributes[attr])}" + ) + + +class TestGoogleAdkPluginIntegration: + """Integration tests using InMemoryExporter to validate actual spans.""" + + def setup_method(self): + """Set up test fixtures for each test.""" + # Create independent providers and exporters + self.tracer_provider = trace_sdk.TracerProvider() + self.span_exporter = InMemorySpanExporter() + self.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + + self.metric_reader = InMemoryMetricReader() + self.meter_provider = metrics_sdk.MeterProvider( + metric_readers=[self.metric_reader] + ) + + # Create instrumentor + self.instrumentor = GoogleAdkInstrumentor() + + # Create validator + self.validator = OTelGenAISpanValidator() + + # Clean up any existing instrumentation + if self.instrumentor.is_instrumented_by_opentelemetry: + self.instrumentor.uninstrument() + + # Clear any existing spans + self.span_exporter.clear() + + def teardown_method(self): + """Clean up after each test.""" + try: + if self.instrumentor.is_instrumented_by_opentelemetry: + self.instrumentor.uninstrument() + except Exception: + pass + + # Clear spans + self.span_exporter.clear() + + @pytest.mark.asyncio + async def test_llm_span_attributes_semantic_conventions(self): + """ + Test that LLM spans follow the latest OTel GenAI semantic conventions. + + Validates: + - Span name format: "chat {model}" + - Required attributes: gen_ai.operation.name, gen_ai.provider.name + - Provider name instead of gen_ai.system + - No non-standard attributes + """ + # Instrument the plugin + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock LLM request + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.config.max_tokens = 1000 + mock_llm_request.config.temperature = 0.7 + mock_llm_request.config.top_p = 0.9 + mock_llm_request.config.top_k = 40 + mock_llm_request.contents = ["test message"] + mock_llm_request.stream = False + + # Create mock response + mock_llm_response = Mock() + mock_llm_response.model = "gemini-pro-001" + mock_llm_response.finish_reason = "stop" + mock_llm_response.content = "test response" + mock_llm_response.usage_metadata = Mock() + mock_llm_response.usage_metadata.prompt_token_count = 100 + mock_llm_response.usage_metadata.candidates_token_count = 50 + + mock_callback_context = create_mock_callback_context( + "conv_123", "user_456" + ) + + # Execute LLM span lifecycle + await plugin.before_model_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + ) + await plugin.after_model_callback( + callback_context=mock_callback_context, + llm_response=mock_llm_response, + ) + + # Get finished spans from InMemoryExporter + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 1, "Should have exactly 1 LLM span" + + llm_span = spans[0] + + # Validate span name follows OTel convention: "chat {model}" + assert llm_span.name == "chat gemini-pro", ( + f"Expected span name 'chat gemini-pro', got '{llm_span.name}'" + ) + + # Validate span attributes using validator + validation_result = self.validator.validate_span(llm_span, "chat") + + # Check for errors + assert len(validation_result["errors"]) == 0, ( + f"Validation errors: {validation_result['errors']}" + ) + + # Validate specific required attributes + attributes = llm_span.attributes + assert attributes.get("gen_ai.operation.name") == "chat", ( + "Should have gen_ai.operation.name = 'chat'" + ) + assert "gen_ai.provider.name" in attributes, ( + "Should have gen_ai.provider.name (not gen_ai.system)" + ) + assert attributes.get("gen_ai.request.model") == "gemini-pro" + assert attributes.get("gen_ai.response.model") == "gemini-pro-001" + + # Validate token usage attributes + assert attributes.get("gen_ai.usage.input_tokens") == 100 + assert attributes.get("gen_ai.usage.output_tokens") == 50 + + # Validate conversation tracking uses correct attributes + assert "gen_ai.conversation.id" in attributes, ( + "Should use gen_ai.conversation.id (not gen_ai.session.id)" + ) + assert attributes.get("gen_ai.conversation.id") == "conv_123" + assert "enduser.id" in attributes, ( + "Should use enduser.id (not gen_ai.user.id)" + ) + assert attributes.get("enduser.id") == "user_456" + + # Validate finish_reasons is array + assert "gen_ai.response.finish_reasons" in attributes, ( + "Should have gen_ai.response.finish_reasons (array)" + ) + finish_reasons = attributes.get("gen_ai.response.finish_reasons") + assert isinstance(finish_reasons, (list, tuple)), ( + "gen_ai.response.finish_reasons should be array" + ) + + @pytest.mark.asyncio + async def test_agent_span_attributes_semantic_conventions(self): + """ + Test that Agent spans follow OTel GenAI semantic conventions. + + Validates: + - Span name format: "invoke_agent {agent_name}" + - gen_ai.operation.name = "invoke_agent" + - Agent attributes with gen_ai. prefix + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock agent + mock_agent = Mock() + mock_agent.name = "weather_agent" + mock_agent.description = "Agent for weather queries" + mock_agent.sub_agents = [] # Simple agent, not a chain + + mock_callback_context = create_mock_callback_context( + "session_789", "user_999" + ) + + # Execute Agent span lifecycle + await plugin.before_agent_callback( + agent=mock_agent, callback_context=mock_callback_context + ) + await plugin.after_agent_callback( + agent=mock_agent, callback_context=mock_callback_context + ) + + # Get finished spans + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 1, "Should have exactly 1 Agent span" + + agent_span = spans[0] + + # Validate span name: "invoke_agent {agent_name}" + assert agent_span.name == "invoke_agent weather_agent", ( + f"Expected span name 'invoke_agent weather_agent', got '{agent_span.name}'" + ) + + # Validate attributes + validation_result = self.validator.validate_span( + agent_span, "invoke_agent" + ) + assert len(validation_result["errors"]) == 0, ( + f"Validation errors: {validation_result['errors']}" + ) + + attributes = agent_span.attributes + assert attributes.get("gen_ai.operation.name") == "invoke_agent" + + # Validate agent attributes have gen_ai. prefix + assert ( + "gen_ai.agent.name" in attributes or "agent.name" in attributes + ), "Should have agent name attribute" + assert ( + "gen_ai.agent.description" in attributes + or "agent.description" in attributes + ), "Should have agent description attribute" + + @pytest.mark.asyncio + async def test_tool_span_attributes_semantic_conventions(self): + """ + Test that Tool spans follow OTel GenAI semantic conventions. + + Validates: + - Span name format: "execute_tool {tool_name}" + - gen_ai.operation.name = "execute_tool" + - Tool attributes with gen_ai. prefix + - SpanKind = INTERNAL (per OTel convention) + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock tool + mock_tool = Mock() + mock_tool.name = "calculator" + mock_tool.description = "Mathematical calculator" + + mock_tool_args = {"operation": "add", "a": 5, "b": 3} + mock_tool_context = Mock() + mock_tool_context.session_id = "session_456" + mock_result = {"result": 8} + + # Execute Tool span lifecycle + await plugin.before_tool_callback( + tool=mock_tool, + tool_args=mock_tool_args, + tool_context=mock_tool_context, + ) + await plugin.after_tool_callback( + tool=mock_tool, + tool_args=mock_tool_args, + tool_context=mock_tool_context, + result=mock_result, + ) + + # Get finished spans + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 1, "Should have exactly 1 Tool span" + + tool_span = spans[0] + + # Validate span name: "execute_tool {tool_name}" + assert tool_span.name == "execute_tool calculator", ( + f"Expected span name 'execute_tool calculator', got '{tool_span.name}'" + ) + + # Validate SpanKind (should be INTERNAL per OTel convention) + assert tool_span.kind == trace_api.SpanKind.INTERNAL, ( + "Tool spans should use SpanKind.INTERNAL" + ) + + # Validate attributes + validation_result = self.validator.validate_span( + tool_span, "execute_tool" + ) + assert len(validation_result["errors"]) == 0, ( + f"Validation errors: {validation_result['errors']}" + ) + + attributes = tool_span.attributes + assert attributes.get("gen_ai.operation.name") == "execute_tool" + + # Validate tool attributes + assert attributes.get("gen_ai.tool.name") == "calculator" + assert ( + attributes.get("gen_ai.tool.description") + == "Mathematical calculator" + ) + + @pytest.mark.asyncio + async def test_runner_span_attributes(self): + """Test Runner span creation and attributes.""" + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock invocation context + mock_invocation_context = Mock() + mock_invocation_context.invocation_id = "run_12345" + mock_invocation_context.app_name = "test_app" + mock_invocation_context.session = Mock() + mock_invocation_context.session.id = "session_111" + mock_invocation_context.user_id = "user_222" + + # Execute Runner span lifecycle + await plugin.before_run_callback( + invocation_context=mock_invocation_context + ) + await plugin.after_run_callback( + invocation_context=mock_invocation_context + ) + + # Get finished spans + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 1, "Should have exactly 1 Runner span" + + runner_span = spans[0] + + # Validate span name (runner uses agent-style naming) + assert runner_span.name == "invoke_agent test_app", ( + f"Expected span name 'invoke_agent test_app', got '{runner_span.name}'" + ) + + # Validate attributes + attributes = runner_span.attributes + assert attributes.get("gen_ai.operation.name") == "invoke_agent" + # Note: runner.app_name is namespaced with google_adk prefix + assert attributes.get("google_adk.runner.app_name") == "test_app" + + @pytest.mark.asyncio + async def test_error_handling_attributes(self): + """ + Test error handling and span status. + + Validates: + - Span status set to ERROR + - error.type attribute (not error.message per OTel) + - Span description contains error message + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create mock LLM request + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.contents = None + + mock_callback_context = create_mock_callback_context( + "session_err", "user_err" + ) + + # Create error + test_error = Exception("API rate limit exceeded") + + # Execute error scenario + await plugin.before_model_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + ) + await plugin.on_model_error_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + error=test_error, + ) + + # Get finished spans + spans = self.span_exporter.get_finished_spans() + assert len(spans) == 1, "Should have exactly 1 error span" + + error_span = spans[0] + + # Validate span status + assert error_span.status.status_code == trace_api.StatusCode.ERROR, ( + "Error span should have ERROR status" + ) + assert "API rate limit exceeded" in error_span.status.description, ( + "Error description should contain error message" + ) + + # Validate error attributes + attributes = error_span.attributes + assert "error.type" in attributes, "Should have error.type attribute" + assert attributes["error.type"] == "Exception" + + # Note: error.message is non-standard, OTel recommends using span status + # but we may include it for debugging purposes + + @pytest.mark.asyncio + async def test_metrics_recorded_with_correct_dimensions(self): + """ + Test that metrics are recorded with correct OTel GenAI dimensions. + + Validates: + - gen_ai.client.operation.duration histogram + - gen_ai.client.token.usage histogram + - Correct dimension attributes + """ + # Instrument + self.instrumentor.instrument( + tracer_provider=self.tracer_provider, + meter_provider=self.meter_provider, + ) + + plugin = self.instrumentor._plugin + + # Create and execute LLM span + mock_llm_request = Mock() + mock_llm_request.model = "gemini-pro" + mock_llm_request.config = Mock() + mock_llm_request.config.max_tokens = 500 + mock_llm_request.config.temperature = 0.5 + mock_llm_request.contents = ["test"] + + mock_llm_response = Mock() + mock_llm_response.model = "gemini-pro" + mock_llm_response.finish_reason = "stop" + mock_llm_response.usage_metadata = Mock() + mock_llm_response.usage_metadata.prompt_token_count = 50 + mock_llm_response.usage_metadata.candidates_token_count = 30 + + mock_callback_context = create_mock_callback_context() + + await plugin.before_model_callback( + callback_context=mock_callback_context, + llm_request=mock_llm_request, + ) + await plugin.after_model_callback( + callback_context=mock_callback_context, + llm_response=mock_llm_response, + ) + + # Get metrics data + metrics_data = self.metric_reader.get_metrics_data() + + # Validate metrics exist + assert metrics_data is not None, "Should have metrics data" + + # Note: Detailed metric validation would require iterating through + # metrics_data.resource_metrics to find the specific histograms + # and verify their attributes match OTel GenAI conventions + + +# Run tests +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tox-loongsuite.ini b/tox-loongsuite.ini index dca49b0c5..e2c78c608 100644 --- a/tox-loongsuite.ini +++ b/tox-loongsuite.ini @@ -24,6 +24,10 @@ envlist = py3{10,11,12,13}-test-loongsuite-instrumentation-claude-agent-sdk-{oldest,latest} lint-loongsuite-instrumentation-claude-agent-sdk + ; loongsuite-instrumentation-google-adk + py3{9,10,11,12,13}-test-loongsuite-instrumentation-google-adk-{oldest,latest} + lint-loongsuite-instrumentation-google-adk + ; ; loongsuite-instrumentation-agno ; py3{9,10,11,12,13}-test-loongsuite-instrumentation-agno ; lint-loongsuite-instrumentation-agno @@ -70,6 +74,11 @@ 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 + google-adk-oldest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.oldest.txt + google-adk-latest: {[testenv]test_deps} + google-adk-latest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.latest.txt + lint-loongsuite-instrumentation-google-adk: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests/requirements.oldest.txt + loongsuite-agno: {[testenv]test_deps} loongsuite-agno: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-agno/test-requirements.txt @@ -113,6 +122,9 @@ commands = 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-google-adk: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-google-adk/tests {posargs} + lint-loongsuite-instrumentation-google-adk: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-google-adk 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